scalebox-sdk 0.1.25__py3-none-any.whl → 1.0.2__py3-none-any.whl
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.
- scalebox/__init__.py +2 -2
- scalebox/api/__init__.py +3 -1
- scalebox/api/client/api/sandboxes/get_sandboxes.py +1 -1
- scalebox/api/client/api/sandboxes/post_sandboxes_sandbox_id_connect.py +193 -0
- scalebox/api/client/models/connect_sandbox.py +59 -0
- scalebox/api/client/models/error.py +2 -2
- scalebox/api/client/models/listed_sandbox.py +24 -3
- scalebox/api/client/models/new_sandbox.py +10 -0
- scalebox/api/client/models/sandbox.py +13 -0
- scalebox/api/client/models/sandbox_detail.py +24 -0
- scalebox/cli.py +125 -125
- scalebox/client/aclient.py +57 -57
- scalebox/client/client.py +102 -102
- scalebox/code_interpreter/__init__.py +12 -12
- scalebox/code_interpreter/charts.py +230 -230
- scalebox/code_interpreter/code_interpreter_async.py +3 -1
- scalebox/code_interpreter/code_interpreter_sync.py +3 -1
- scalebox/code_interpreter/constants.py +3 -3
- scalebox/code_interpreter/exceptions.py +13 -13
- scalebox/code_interpreter/models.py +485 -485
- scalebox/connection_config.py +36 -1
- scalebox/csx_connect/__init__.py +1 -1
- scalebox/csx_connect/client.py +485 -485
- scalebox/csx_desktop/main.py +651 -651
- scalebox/exceptions.py +83 -83
- scalebox/generated/api.py +61 -61
- scalebox/generated/api_pb2.py +203 -203
- scalebox/generated/api_pb2.pyi +956 -956
- scalebox/generated/api_pb2_connect.py +1407 -1407
- scalebox/generated/rpc.py +50 -50
- scalebox/sandbox/main.py +146 -139
- scalebox/sandbox/sandbox_api.py +105 -91
- scalebox/sandbox/signature.py +40 -40
- scalebox/sandbox/utils.py +34 -34
- scalebox/sandbox_async/main.py +226 -44
- scalebox/sandbox_async/sandbox_api.py +124 -3
- scalebox/sandbox_sync/main.py +205 -130
- scalebox/sandbox_sync/sandbox_api.py +119 -3
- scalebox/test/CODE_INTERPRETER_TESTS_READY.md +323 -323
- scalebox/test/README.md +329 -329
- scalebox/test/bedrock_openai_adapter.py +73 -0
- scalebox/test/code_interpreter_test.py +34 -34
- scalebox/test/code_interpreter_test_sync.py +34 -34
- scalebox/test/run_stress_code_interpreter_sync.py +178 -0
- scalebox/test/simple_upload_example.py +131 -0
- scalebox/test/stabitiy_test.py +323 -0
- scalebox/test/test_browser_use.py +27 -0
- scalebox/test/test_browser_use_scalebox.py +62 -0
- scalebox/test/test_code_interpreter_execcode.py +289 -211
- scalebox/test/test_code_interpreter_sync_comprehensive.py +116 -69
- scalebox/test/test_connect_pause_async.py +300 -0
- scalebox/test/test_connect_pause_sync.py +300 -0
- scalebox/test/test_csx_desktop_examples.py +3 -3
- scalebox/test/test_desktop_sandbox_sf.py +112 -0
- scalebox/test/test_download_url.py +41 -0
- scalebox/test/test_existing_sandbox.py +1037 -0
- scalebox/test/test_sandbox_async_comprehensive.py +5 -3
- scalebox/test/test_sandbox_object_storage_example.py +151 -0
- scalebox/test/test_sandbox_object_storage_example_async.py +159 -0
- scalebox/test/test_sandbox_sync_comprehensive.py +1 -1
- scalebox/test/test_sf.py +141 -0
- scalebox/test/test_watch_dir_async.py +58 -0
- scalebox/test/testacreate.py +1 -1
- scalebox/test/testagetinfo.py +1 -3
- scalebox/test/testcomputeuse.py +243 -243
- scalebox/test/testsandbox_api.py +5 -5
- scalebox/test/testsandbox_async.py +17 -47
- scalebox/test/testsandbox_sync.py +19 -15
- scalebox/test/upload_100mb_example.py +377 -0
- scalebox/utils/httpcoreclient.py +297 -297
- scalebox/utils/httpxclient.py +403 -403
- scalebox/version.py +2 -2
- {scalebox_sdk-0.1.25.dist-info → scalebox_sdk-1.0.2.dist-info}/METADATA +1 -1
- {scalebox_sdk-0.1.25.dist-info → scalebox_sdk-1.0.2.dist-info}/RECORD +78 -60
- {scalebox_sdk-0.1.25.dist-info → scalebox_sdk-1.0.2.dist-info}/WHEEL +1 -1
- {scalebox_sdk-0.1.25.dist-info → scalebox_sdk-1.0.2.dist-info}/entry_points.txt +0 -0
- {scalebox_sdk-0.1.25.dist-info → scalebox_sdk-1.0.2.dist-info}/licenses/LICENSE +0 -0
- {scalebox_sdk-0.1.25.dist-info → scalebox_sdk-1.0.2.dist-info}/top_level.txt +0 -0
scalebox/csx_desktop/main.py
CHANGED
|
@@ -1,651 +1,651 @@
|
|
|
1
|
-
import time
|
|
2
|
-
from re import search as re_search
|
|
3
|
-
from shlex import quote as quote_string
|
|
4
|
-
from typing import Callable, Dict, Iterator, Literal, Optional, Tuple, Union, overload
|
|
5
|
-
from uuid import uuid4
|
|
6
|
-
|
|
7
|
-
from httpx._types import ProxyTypes
|
|
8
|
-
|
|
9
|
-
from ..exceptions import TimeoutException
|
|
10
|
-
from ..sandbox_sync import CommandExitException, CommandHandle, CommandResult
|
|
11
|
-
from ..sandbox_sync import Sandbox as SandboxBase
|
|
12
|
-
|
|
13
|
-
MOUSE_BUTTONS = {"left": 1, "right": 3, "middle": 2}
|
|
14
|
-
|
|
15
|
-
KEYS = {
|
|
16
|
-
"alt": "Alt_L",
|
|
17
|
-
"alt_left": "Alt_L",
|
|
18
|
-
"alt_right": "Alt_R",
|
|
19
|
-
"backspace": "BackSpace",
|
|
20
|
-
"break": "Pause",
|
|
21
|
-
"caps_lock": "Caps_Lock",
|
|
22
|
-
"cmd": "Super_L",
|
|
23
|
-
"command": "Super_L",
|
|
24
|
-
"control": "Control_L",
|
|
25
|
-
"control_left": "Control_L",
|
|
26
|
-
"control_right": "Control_R",
|
|
27
|
-
"ctrl": "Control_L",
|
|
28
|
-
"del": "Delete",
|
|
29
|
-
"delete": "Delete",
|
|
30
|
-
"down": "Down",
|
|
31
|
-
"end": "End",
|
|
32
|
-
"enter": "Return",
|
|
33
|
-
"esc": "Escape",
|
|
34
|
-
"escape": "Escape",
|
|
35
|
-
"f1": "F1",
|
|
36
|
-
"f2": "F2",
|
|
37
|
-
"f3": "F3",
|
|
38
|
-
"f4": "F4",
|
|
39
|
-
"f5": "F5",
|
|
40
|
-
"f6": "F6",
|
|
41
|
-
"f7": "F7",
|
|
42
|
-
"f8": "F8",
|
|
43
|
-
"f9": "F9",
|
|
44
|
-
"f10": "F10",
|
|
45
|
-
"f11": "F11",
|
|
46
|
-
"f12": "F12",
|
|
47
|
-
"home": "Home",
|
|
48
|
-
"insert": "Insert",
|
|
49
|
-
"left": "Left",
|
|
50
|
-
"menu": "Menu",
|
|
51
|
-
"meta": "Meta_L",
|
|
52
|
-
"num_lock": "Num_Lock",
|
|
53
|
-
"page_down": "Page_Down",
|
|
54
|
-
"page_up": "Page_Up",
|
|
55
|
-
"pause": "Pause",
|
|
56
|
-
"print": "Print",
|
|
57
|
-
"right": "Right",
|
|
58
|
-
"scroll_lock": "Scroll_Lock",
|
|
59
|
-
"shift": "Shift_L",
|
|
60
|
-
"shift_left": "Shift_L",
|
|
61
|
-
"shift_right": "Shift_R",
|
|
62
|
-
"space": "space",
|
|
63
|
-
"super": "Super_L",
|
|
64
|
-
"super_left": "Super_L",
|
|
65
|
-
"super_right": "Super_R",
|
|
66
|
-
"tab": "Tab",
|
|
67
|
-
"up": "Up",
|
|
68
|
-
"win": "Super_L",
|
|
69
|
-
"windows": "Super_L",
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def map_key(key: str) -> str:
|
|
74
|
-
lower_key = key.lower()
|
|
75
|
-
if lower_key in KEYS:
|
|
76
|
-
return KEYS[lower_key]
|
|
77
|
-
return lower_key
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
class _VNCServer:
|
|
81
|
-
def __init__(self, desktop: "Sandbox") -> None:
|
|
82
|
-
self.__novnc_handle: Optional[CommandHandle] = None
|
|
83
|
-
|
|
84
|
-
self._vnc_port = 5900
|
|
85
|
-
self._port = 6080
|
|
86
|
-
self._novnc_auth_enabled = False
|
|
87
|
-
self._novnc_password = None
|
|
88
|
-
|
|
89
|
-
self._url = f"https://{self._port}-{desktop.sandbox_domain}/vnc.html"
|
|
90
|
-
# self._url=""
|
|
91
|
-
|
|
92
|
-
self.__desktop = desktop
|
|
93
|
-
|
|
94
|
-
def _wait_for_port(self, port: int) -> bool:
|
|
95
|
-
return self.__desktop._wait_and_verify(
|
|
96
|
-
f'netstat -tuln | grep ":{port} "', lambda r: r.stdout.strip() != ""
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
def _check_vnc_running(self) -> bool:
|
|
100
|
-
try:
|
|
101
|
-
self.__desktop.commands.run("pgrep -x x11vnc")
|
|
102
|
-
return True
|
|
103
|
-
except CommandExitException:
|
|
104
|
-
return False
|
|
105
|
-
|
|
106
|
-
@staticmethod
|
|
107
|
-
def _generate_password(length: int = 16) -> str:
|
|
108
|
-
import secrets
|
|
109
|
-
import string
|
|
110
|
-
|
|
111
|
-
characters = string.ascii_letters + string.digits
|
|
112
|
-
return "".join(secrets.choice(characters) for _ in range(length))
|
|
113
|
-
|
|
114
|
-
def get_url(
|
|
115
|
-
self,
|
|
116
|
-
auto_connect: bool = True,
|
|
117
|
-
view_only: bool = False,
|
|
118
|
-
resize: str = "scale",
|
|
119
|
-
auth_key: Optional[str] = None,
|
|
120
|
-
) -> str:
|
|
121
|
-
params = []
|
|
122
|
-
if auto_connect:
|
|
123
|
-
params.append("autoconnect=true")
|
|
124
|
-
if view_only:
|
|
125
|
-
params.append(f"view_only=true")
|
|
126
|
-
if resize:
|
|
127
|
-
params.append(f"resize={resize}")
|
|
128
|
-
if auth_key:
|
|
129
|
-
params.append(f"password={auth_key}")
|
|
130
|
-
if params:
|
|
131
|
-
return f"{self._url}?{'&'.join(params)}"
|
|
132
|
-
return self._url
|
|
133
|
-
|
|
134
|
-
def get_auth_key(self) -> str:
|
|
135
|
-
if not self._novnc_password:
|
|
136
|
-
raise RuntimeError(
|
|
137
|
-
"Unable to retrieve stream auth key, check if require_auth is enabled"
|
|
138
|
-
)
|
|
139
|
-
return self._novnc_password
|
|
140
|
-
|
|
141
|
-
def start(
|
|
142
|
-
self,
|
|
143
|
-
vnc_port: Optional[int] = None,
|
|
144
|
-
port: Optional[int] = None,
|
|
145
|
-
require_auth: bool = False,
|
|
146
|
-
window_id: Optional[str] = None,
|
|
147
|
-
) -> None:
|
|
148
|
-
# If stream is already running, throw an error
|
|
149
|
-
if self._check_vnc_running():
|
|
150
|
-
raise RuntimeError("Stream is already running")
|
|
151
|
-
|
|
152
|
-
# Update parameters if provided
|
|
153
|
-
self._vnc_port = vnc_port or self._vnc_port
|
|
154
|
-
self._port = port or self._port
|
|
155
|
-
self._novnc_auth_enabled = require_auth or self._novnc_auth_enabled
|
|
156
|
-
self._novnc_password = self._generate_password() if require_auth else None
|
|
157
|
-
|
|
158
|
-
# Update URL with new port
|
|
159
|
-
# self._url = ""
|
|
160
|
-
# self._url = f"https://{self.__desktop.get_host(self._port)}/vnc.html"
|
|
161
|
-
|
|
162
|
-
# Set up VNC command
|
|
163
|
-
pwd_flag = "-nopw"
|
|
164
|
-
if self._novnc_auth_enabled:
|
|
165
|
-
self.__desktop.commands.run("mkdir -p ~/.vnc")
|
|
166
|
-
self.__desktop.commands.run(
|
|
167
|
-
f"x11vnc -storepasswd {self._novnc_password} ~/.vnc/passwd"
|
|
168
|
-
)
|
|
169
|
-
pwd_flag = "-usepw"
|
|
170
|
-
window_id_flag = ""
|
|
171
|
-
if window_id:
|
|
172
|
-
window_id_flag = f"-id {window_id}"
|
|
173
|
-
|
|
174
|
-
vnc_command = (
|
|
175
|
-
f"DISPLAY={self.__desktop._display} x11vnc -bg -display {self.__desktop._display} -forever -wait 50 -shared "
|
|
176
|
-
f"-rfbport {self._vnc_port} {pwd_flag} 2>/tmp/x11vnc_stderr.log {window_id_flag}"
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
novnc_command = (
|
|
180
|
-
f"cd /opt/noVNC/utils && ./novnc_proxy --vnc localhost:{self._vnc_port} "
|
|
181
|
-
f"--listen {self._port} --web /opt/noVNC > /tmp/novnc.log 2>&1 &"
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
self.__desktop.commands.run(vnc_command)
|
|
185
|
-
self.__novnc_handle = self.__desktop.commands.run(
|
|
186
|
-
novnc_command, background=True, timeout=0
|
|
187
|
-
)
|
|
188
|
-
if not self._wait_for_port(self._port):
|
|
189
|
-
raise TimeoutException("Could not start noVNC server")
|
|
190
|
-
|
|
191
|
-
def stop(self) -> None:
|
|
192
|
-
if self._check_vnc_running():
|
|
193
|
-
self.__desktop.commands.run("pkill x11vnc")
|
|
194
|
-
|
|
195
|
-
if self.__novnc_handle:
|
|
196
|
-
self.__novnc_handle.kill()
|
|
197
|
-
self.__novnc_handle = None
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
class Sandbox(SandboxBase):
|
|
201
|
-
default_template = "desktop"
|
|
202
|
-
|
|
203
|
-
# def __init__(
|
|
204
|
-
# self,
|
|
205
|
-
# resolution: Optional[Tuple[int, int]] = None,
|
|
206
|
-
# dpi: Optional[int] = None,
|
|
207
|
-
# display: Optional[str] = None,
|
|
208
|
-
# template: Optional[str] = None,
|
|
209
|
-
# timeout: Optional[int] = None,
|
|
210
|
-
# metadata: Optional[Dict[str, str]] = None,
|
|
211
|
-
# envs: Optional[Dict[str, str]] = None,
|
|
212
|
-
# api_key: Optional[str] = None,
|
|
213
|
-
# domain: Optional[str] = None,
|
|
214
|
-
# debug: Optional[bool] = None,
|
|
215
|
-
# sandbox_id: Optional[str] = None,
|
|
216
|
-
# request_timeout: Optional[float] = None,
|
|
217
|
-
# proxy: Optional[ProxyTypes] = None,
|
|
218
|
-
# ):
|
|
219
|
-
# """
|
|
220
|
-
# Create a new desktop sandbox.
|
|
221
|
-
#
|
|
222
|
-
# By default, the sandbox is created from the `desktop` template.
|
|
223
|
-
#
|
|
224
|
-
# :param resolution: Startup the desktop with custom screen resolution. Defaults to (1024, 768)
|
|
225
|
-
# :param dpi: Startup the desktop with custom DPI. Defaults to 96
|
|
226
|
-
# :param display: Startup the desktop with custom display. Defaults to ":0"
|
|
227
|
-
# :param template: Sandbox template name or ID
|
|
228
|
-
# :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. Maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users
|
|
229
|
-
# :param metadata: Custom metadata for the sandbox
|
|
230
|
-
# :param envs: Custom environment variables for the sandbox
|
|
231
|
-
# :param api_key: API Key to use for authentication, defaults to `SBX_API_KEY` environment variable
|
|
232
|
-
# :param domain: Domain to use for authentication, defaults to `SBX_DOMAIN` environment variable
|
|
233
|
-
# :param debug: If True, the sandbox will be created in debug mode, defaults to `SBX_DEBUG` environment variable
|
|
234
|
-
# :param sandbox_id: Sandbox ID to connect to, defaults to `SBX_SANDBOX_ID` environment variable
|
|
235
|
-
# :param request_timeout: Timeout for the request in **seconds**
|
|
236
|
-
# :param proxy: Proxy to use for the request and for the requests made to the returned sandbox
|
|
237
|
-
#
|
|
238
|
-
# :return: sandbox instance for the new sandbox
|
|
239
|
-
# """
|
|
240
|
-
# self._display = display or ":0"
|
|
241
|
-
# self._last_xfce4_pid = None
|
|
242
|
-
#
|
|
243
|
-
# # Initialize environment variables with DISPLAY
|
|
244
|
-
# if envs is None:
|
|
245
|
-
# envs = {}
|
|
246
|
-
# envs["DISPLAY"] = self._display
|
|
247
|
-
#
|
|
248
|
-
# _base = SandboxBase.create(
|
|
249
|
-
# template=template,
|
|
250
|
-
# timeout=timeout,
|
|
251
|
-
# metadata=metadata,
|
|
252
|
-
# envs=envs,
|
|
253
|
-
# api_key=api_key,
|
|
254
|
-
# domain=domain,
|
|
255
|
-
# debug=debug,
|
|
256
|
-
# sandbox_id=sandbox_id,
|
|
257
|
-
# request_timeout=request_timeout,
|
|
258
|
-
# proxy=proxy,
|
|
259
|
-
# )
|
|
260
|
-
# self.__dict__.update(_base.__dict__)
|
|
261
|
-
#
|
|
262
|
-
# # Only initialize desktop environment if we're not just connecting to an existing sandbox
|
|
263
|
-
# print("--------------"+sandbox_id)
|
|
264
|
-
# if not sandbox_id:
|
|
265
|
-
# width, height = resolution or (1024, 768)
|
|
266
|
-
#
|
|
267
|
-
# # self.commands.run(
|
|
268
|
-
# # f"Xvfb {self._display} -ac -screen 0 {width}x{height}x24"
|
|
269
|
-
# # f" -retro -dpi {dpi or 96} -nolisten tcp -nolisten unix",
|
|
270
|
-
# # background=True,
|
|
271
|
-
# # timeout=0,
|
|
272
|
-
# # )
|
|
273
|
-
# self.commands.run(
|
|
274
|
-
# f"Xvfb {self._display} -ac -screen 0 {width}x{height}x24"
|
|
275
|
-
# f" -retro -dpi {dpi or 96} -nolisten tcp -nolisten unix &",
|
|
276
|
-
# background=True,
|
|
277
|
-
# timeout=0,
|
|
278
|
-
# )
|
|
279
|
-
# print("11111111111")
|
|
280
|
-
# if not self._wait_and_verify(
|
|
281
|
-
# f"xdpyinfo -display {self._display}", lambda r: r.exit_code == 0
|
|
282
|
-
# ):
|
|
283
|
-
# raise TimeoutException("Could not start Xvfb")
|
|
284
|
-
# print("2222222222")
|
|
285
|
-
# self.__vnc_server = _VNCServer(self)
|
|
286
|
-
# self._start_xfce4()
|
|
287
|
-
# print("333333333333")
|
|
288
|
-
# else:
|
|
289
|
-
# # When connecting to existing sandbox, just initialize VNC server
|
|
290
|
-
# self.__vnc_server = _VNCServer(self)
|
|
291
|
-
|
|
292
|
-
@classmethod
|
|
293
|
-
def create(
|
|
294
|
-
cls,
|
|
295
|
-
resolution: Optional[Tuple[int, int]] = None,
|
|
296
|
-
dpi: Optional[int] = None,
|
|
297
|
-
display: Optional[str] = None,
|
|
298
|
-
template: Optional[str] = None,
|
|
299
|
-
timeout: Optional[int] = None,
|
|
300
|
-
metadata: Optional[Dict[str, str]] = None,
|
|
301
|
-
envs: Optional[Dict[str, str]] = None,
|
|
302
|
-
api_key: Optional[str] = None,
|
|
303
|
-
domain: Optional[str] = None,
|
|
304
|
-
debug: Optional[bool] = None,
|
|
305
|
-
sandbox_id: Optional[str] = None,
|
|
306
|
-
request_timeout: Optional[float] = None,
|
|
307
|
-
proxy: Optional[ProxyTypes] = None,
|
|
308
|
-
) -> "Sandbox":
|
|
309
|
-
"""
|
|
310
|
-
Synchronously create or connect to a desktop sandbox.
|
|
311
|
-
|
|
312
|
-
Parameters have the same meaning as the base class. Xvfb + xfce4 will be automatically started on first creation.
|
|
313
|
-
"""
|
|
314
|
-
display = display or ":0"
|
|
315
|
-
if envs is None:
|
|
316
|
-
envs = {}
|
|
317
|
-
envs["DISPLAY"] = display
|
|
318
|
-
|
|
319
|
-
# 1. Let the parent class factory actually create / reuse
|
|
320
|
-
base = SandboxBase.create(
|
|
321
|
-
template=template or cls.default_template,
|
|
322
|
-
timeout=timeout,
|
|
323
|
-
metadata=metadata,
|
|
324
|
-
envs=envs,
|
|
325
|
-
api_key=api_key,
|
|
326
|
-
domain=domain,
|
|
327
|
-
debug=debug,
|
|
328
|
-
sandbox_id=sandbox_id,
|
|
329
|
-
request_timeout=request_timeout,
|
|
330
|
-
proxy=proxy,
|
|
331
|
-
)
|
|
332
|
-
|
|
333
|
-
# 2. Dynamically upgrade type to Sandbox
|
|
334
|
-
base.__class__ = cls
|
|
335
|
-
base._display = display
|
|
336
|
-
base._last_xfce4_pid = None
|
|
337
|
-
|
|
338
|
-
# 3. Only start desktop on first creation
|
|
339
|
-
if not sandbox_id:
|
|
340
|
-
base._start_desktop(resolution, dpi)
|
|
341
|
-
else:
|
|
342
|
-
# Connect to existing sandbox, only initialize VNC
|
|
343
|
-
base.__vnc_server = _VNCServer(base)
|
|
344
|
-
|
|
345
|
-
return base
|
|
346
|
-
|
|
347
|
-
# ====================== Subclass Private Methods ======================
|
|
348
|
-
def _start_desktop(
|
|
349
|
-
self,
|
|
350
|
-
resolution: Optional[Tuple[int, int]] = None,
|
|
351
|
-
dpi: Optional[int] = None,
|
|
352
|
-
) -> None:
|
|
353
|
-
"""Start Xvfb and wait for success, then start xfce4 + VNC."""
|
|
354
|
-
width, height = resolution or (1024, 768)
|
|
355
|
-
self.commands.run(
|
|
356
|
-
f"Xvfb {self._display} -ac -screen 0 {width}x{height}x24 "
|
|
357
|
-
f"-retro -dpi {dpi or 96} -nolisten tcp -nolisten unix",
|
|
358
|
-
background=True,
|
|
359
|
-
timeout=0,
|
|
360
|
-
)
|
|
361
|
-
if not self._wait_and_verify(
|
|
362
|
-
f"xdpyinfo -display {self._display}",
|
|
363
|
-
lambda r: r.exit_code == 0,
|
|
364
|
-
):
|
|
365
|
-
raise TimeoutException("Could not start Xvfb")
|
|
366
|
-
self.__vnc_server = _VNCServer(self)
|
|
367
|
-
self._start_xfce4()
|
|
368
|
-
|
|
369
|
-
def _wait_and_verify(
|
|
370
|
-
self,
|
|
371
|
-
cmd: str,
|
|
372
|
-
on_result: Callable[[CommandResult], bool],
|
|
373
|
-
timeout: int = 10,
|
|
374
|
-
interval: float = 0.5,
|
|
375
|
-
) -> bool:
|
|
376
|
-
|
|
377
|
-
elapsed = 0
|
|
378
|
-
while elapsed < timeout:
|
|
379
|
-
try:
|
|
380
|
-
if on_result(self.commands.run(cmd)):
|
|
381
|
-
return True
|
|
382
|
-
except CommandExitException:
|
|
383
|
-
continue
|
|
384
|
-
|
|
385
|
-
time.sleep(interval)
|
|
386
|
-
elapsed += interval
|
|
387
|
-
|
|
388
|
-
return False
|
|
389
|
-
|
|
390
|
-
def _start_xfce4(self):
|
|
391
|
-
"""
|
|
392
|
-
Start xfce4 session if logged out or not running.
|
|
393
|
-
"""
|
|
394
|
-
if self._last_xfce4_pid is None or "[xfce4-session] <defunct>" in (
|
|
395
|
-
self.commands.run(
|
|
396
|
-
f"ps aux | grep {self._last_xfce4_pid} | grep -v grep | head -n 1"
|
|
397
|
-
).stdout.strip()
|
|
398
|
-
):
|
|
399
|
-
self._last_xfce4_pid = self.commands.run(
|
|
400
|
-
f"mkdir -p $HOME/.config $HOME/.cache $HOME/.dbus && DISPLAY={self._display} startxfce4",
|
|
401
|
-
background=True,
|
|
402
|
-
timeout=0,
|
|
403
|
-
envs={"HOME": "/workspace"},
|
|
404
|
-
).pid
|
|
405
|
-
|
|
406
|
-
@property
|
|
407
|
-
def stream(self) -> _VNCServer:
|
|
408
|
-
return self.__vnc_server
|
|
409
|
-
|
|
410
|
-
@overload
|
|
411
|
-
def screenshot(self, format: Literal["stream"]) -> Iterator[bytes]:
|
|
412
|
-
"""
|
|
413
|
-
Take a screenshot and return it as a stream of bytes.
|
|
414
|
-
"""
|
|
415
|
-
|
|
416
|
-
@overload
|
|
417
|
-
def screenshot(
|
|
418
|
-
self,
|
|
419
|
-
format: Literal["bytes"],
|
|
420
|
-
) -> bytearray:
|
|
421
|
-
"""
|
|
422
|
-
Take a screenshot and return it as a bytearray.
|
|
423
|
-
"""
|
|
424
|
-
|
|
425
|
-
def screenshot(
|
|
426
|
-
self,
|
|
427
|
-
format: Literal["bytes", "stream"] = "bytes",
|
|
428
|
-
):
|
|
429
|
-
"""
|
|
430
|
-
Take a screenshot and return it in the specified format.
|
|
431
|
-
|
|
432
|
-
:param format: The format of the screenshot. Can be 'bytes', 'blob', or 'stream'.
|
|
433
|
-
:returns: The screenshot in the specified format.
|
|
434
|
-
"""
|
|
435
|
-
screenshot_path = f"/tmp/screenshot-{uuid4()}.png"
|
|
436
|
-
|
|
437
|
-
self.commands.run(f"DISPLAY={self._display} scrot --pointer {screenshot_path}")
|
|
438
|
-
|
|
439
|
-
file = self.files.read(screenshot_path, format=format)
|
|
440
|
-
self.files.remove(screenshot_path)
|
|
441
|
-
return file
|
|
442
|
-
|
|
443
|
-
def left_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
444
|
-
"""
|
|
445
|
-
Left click on the mouse position.
|
|
446
|
-
"""
|
|
447
|
-
if x and y:
|
|
448
|
-
self.move_mouse(x, y)
|
|
449
|
-
self.commands.run(f"DISPLAY={self._display} xdotool click 1")
|
|
450
|
-
|
|
451
|
-
def double_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
452
|
-
"""
|
|
453
|
-
Double left click on the mouse position.
|
|
454
|
-
"""
|
|
455
|
-
if x and y:
|
|
456
|
-
self.move_mouse(x, y)
|
|
457
|
-
self.commands.run(f"DISPLAY={self._display} xdotool click --repeat 2 1")
|
|
458
|
-
|
|
459
|
-
def right_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
460
|
-
if (x is None) != (y is None):
|
|
461
|
-
raise ValueError("Both x and y must be provided together")
|
|
462
|
-
"""
|
|
463
|
-
Right click on the mouse position.
|
|
464
|
-
"""
|
|
465
|
-
if x and y:
|
|
466
|
-
self.move_mouse(x, y)
|
|
467
|
-
self.commands.run(f"DISPLAY={self._display} xdotool click 3")
|
|
468
|
-
|
|
469
|
-
def middle_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
470
|
-
"""
|
|
471
|
-
Middle click on the mouse position.
|
|
472
|
-
"""
|
|
473
|
-
if x and y:
|
|
474
|
-
self.move_mouse(x, y)
|
|
475
|
-
self.commands.run(f"DISPLAY={self._display} xdotool click 2")
|
|
476
|
-
|
|
477
|
-
def scroll(self, direction: Literal["up", "down"] = "down", amount: int = 1):
|
|
478
|
-
"""
|
|
479
|
-
Scroll the mouse wheel by the given amount.
|
|
480
|
-
|
|
481
|
-
:param direction: The direction to scroll. Can be "up" or "down".
|
|
482
|
-
:param amount: The amount to scroll.
|
|
483
|
-
"""
|
|
484
|
-
self.commands.run(
|
|
485
|
-
f"DISPLAY={self._display} xdotool click --repeat {amount} {'4' if direction == 'up' else '5'}"
|
|
486
|
-
)
|
|
487
|
-
|
|
488
|
-
def move_mouse(self, x: int, y: int):
|
|
489
|
-
"""
|
|
490
|
-
Move the mouse to the given coordinates.
|
|
491
|
-
|
|
492
|
-
:param x: The x coordinate.
|
|
493
|
-
:param y: The y coordinate.
|
|
494
|
-
"""
|
|
495
|
-
self.commands.run(f"DISPLAY={self._display} xdotool mousemove --sync {x} {y}")
|
|
496
|
-
|
|
497
|
-
def mouse_press(self, button: Literal["left", "right", "middle"] = "left"):
|
|
498
|
-
"""
|
|
499
|
-
Press the mouse button.
|
|
500
|
-
"""
|
|
501
|
-
self.commands.run(
|
|
502
|
-
f"DISPLAY={self._display} xdotool mousedown {MOUSE_BUTTONS[button]}"
|
|
503
|
-
)
|
|
504
|
-
|
|
505
|
-
def mouse_release(self, button: Literal["left", "right", "middle"] = "left"):
|
|
506
|
-
"""
|
|
507
|
-
Release the mouse button.
|
|
508
|
-
"""
|
|
509
|
-
self.commands.run(
|
|
510
|
-
f"DISPLAY={self._display} xdotool mouseup {MOUSE_BUTTONS[button]}"
|
|
511
|
-
)
|
|
512
|
-
|
|
513
|
-
def get_cursor_position(self) -> tuple[int, int]:
|
|
514
|
-
"""
|
|
515
|
-
Get the current cursor position.
|
|
516
|
-
|
|
517
|
-
:return: A tuple with the x and y coordinates
|
|
518
|
-
:raises RuntimeError: If the cursor position cannot be determined
|
|
519
|
-
"""
|
|
520
|
-
result = self.commands.run(f"DISPLAY={self._display} xdotool getmouselocation")
|
|
521
|
-
|
|
522
|
-
groups = re_search(r"x:(\d+)\s+y:(\d+)", result.stdout)
|
|
523
|
-
if not groups:
|
|
524
|
-
raise RuntimeError(
|
|
525
|
-
f"Failed to parse cursor position from output: {result.stdout}"
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
x, y = groups.group(1), groups.group(2)
|
|
529
|
-
if not x or not y:
|
|
530
|
-
raise RuntimeError(f"Invalid cursor position values: x={x}, y={y}")
|
|
531
|
-
|
|
532
|
-
return int(x), int(y)
|
|
533
|
-
|
|
534
|
-
def get_screen_size(self) -> tuple[int, int]:
|
|
535
|
-
"""
|
|
536
|
-
Get the current screen size.
|
|
537
|
-
|
|
538
|
-
:return: A tuple with the width and height
|
|
539
|
-
:raises RuntimeError: If the screen size cannot be determined
|
|
540
|
-
"""
|
|
541
|
-
result = self.commands.run(f"DISPLAY={self._display} xrandr")
|
|
542
|
-
|
|
543
|
-
_match = re_search(r"(\d+x\d+)", result.stdout)
|
|
544
|
-
if not _match:
|
|
545
|
-
raise RuntimeError(
|
|
546
|
-
f"Failed to parse screen size from output: {result.stdout}"
|
|
547
|
-
)
|
|
548
|
-
|
|
549
|
-
try:
|
|
550
|
-
return tuple(map(int, _match.group(1).split("x"))) # type: ignore
|
|
551
|
-
except (ValueError, IndexError) as e:
|
|
552
|
-
raise RuntimeError(f"Invalid screen size format: {_match.group(1)}") from e
|
|
553
|
-
|
|
554
|
-
def write(self, text: str, *, chunk_size: int = 25, delay_in_ms: int = 75) -> None:
|
|
555
|
-
"""
|
|
556
|
-
Write the given text at the current cursor position.
|
|
557
|
-
|
|
558
|
-
:param text: The text to write.
|
|
559
|
-
:param chunk_size: The size of each chunk of text to write.
|
|
560
|
-
:param delay_in_ms: The delay between each chunk of text.
|
|
561
|
-
"""
|
|
562
|
-
|
|
563
|
-
def break_into_chunks(text: str, n: int):
|
|
564
|
-
for i in range(0, len(text), n):
|
|
565
|
-
yield text[i : i + n]
|
|
566
|
-
|
|
567
|
-
for chunk in break_into_chunks(text, chunk_size):
|
|
568
|
-
self.commands.run(
|
|
569
|
-
f"DISPLAY={self._display} xdotool type --delay {delay_in_ms} {quote_string(chunk)}"
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
def press(self, key: Union[str, list[str]]):
|
|
573
|
-
"""
|
|
574
|
-
Press a key.
|
|
575
|
-
|
|
576
|
-
:param key: The key to press (e.g. "enter", "space", "backspace", etc.).
|
|
577
|
-
"""
|
|
578
|
-
if isinstance(key, list):
|
|
579
|
-
key = "+".join(map_key(k) for k in key)
|
|
580
|
-
else:
|
|
581
|
-
key = map_key(key)
|
|
582
|
-
|
|
583
|
-
self.commands.run(f"DISPLAY={self._display} xdotool key {key}")
|
|
584
|
-
|
|
585
|
-
def drag(self, fr: tuple[int, int], to: tuple[int, int]):
|
|
586
|
-
"""
|
|
587
|
-
Drag the mouse from the given position to the given position.
|
|
588
|
-
|
|
589
|
-
:param from: The starting position.
|
|
590
|
-
:param to: The ending position.
|
|
591
|
-
"""
|
|
592
|
-
self.move_mouse(fr[0], fr[1])
|
|
593
|
-
self.mouse_press()
|
|
594
|
-
self.move_mouse(to[0], to[1])
|
|
595
|
-
self.mouse_release()
|
|
596
|
-
|
|
597
|
-
def wait(self, ms: int):
|
|
598
|
-
"""
|
|
599
|
-
Wait for the given amount of time.
|
|
600
|
-
|
|
601
|
-
:param ms: The amount of time to wait in milliseconds.
|
|
602
|
-
"""
|
|
603
|
-
self.commands.run(f"sleep {ms / 1000}")
|
|
604
|
-
|
|
605
|
-
def open(self, file_or_url: str):
|
|
606
|
-
"""
|
|
607
|
-
Open a file or a URL in the default application.
|
|
608
|
-
|
|
609
|
-
:param file_or_url: The file or URL to open.
|
|
610
|
-
"""
|
|
611
|
-
self.commands.run(
|
|
612
|
-
f"DISPLAY={self._display} xdg-open {file_or_url}", background=True
|
|
613
|
-
)
|
|
614
|
-
|
|
615
|
-
def get_current_window_id(self) -> str:
|
|
616
|
-
"""
|
|
617
|
-
Get the current window ID.
|
|
618
|
-
"""
|
|
619
|
-
return self.commands.run(
|
|
620
|
-
f"DISPLAY={self._display} xdotool getwindowfocus"
|
|
621
|
-
).stdout.strip()
|
|
622
|
-
|
|
623
|
-
def get_application_windows(self, application: str) -> list[str]:
|
|
624
|
-
"""
|
|
625
|
-
Get the window IDs of all windows for the given application.
|
|
626
|
-
"""
|
|
627
|
-
return (
|
|
628
|
-
self.commands.run(
|
|
629
|
-
f"DISPLAY={self._display} xdotool search --onlyvisible --class {application}"
|
|
630
|
-
)
|
|
631
|
-
.stdout.strip()
|
|
632
|
-
.split("\n")
|
|
633
|
-
)
|
|
634
|
-
|
|
635
|
-
def get_window_title(self, window_id: str) -> str:
|
|
636
|
-
"""
|
|
637
|
-
Get the title of the window with the given ID.
|
|
638
|
-
"""
|
|
639
|
-
return self.commands.run(
|
|
640
|
-
f"DISPLAY={self._display} xdotool getwindowname {window_id}"
|
|
641
|
-
).stdout.strip()
|
|
642
|
-
|
|
643
|
-
def launch(self, application: str, uri: Optional[str] = None):
|
|
644
|
-
"""
|
|
645
|
-
Launch an application.
|
|
646
|
-
"""
|
|
647
|
-
self.commands.run(
|
|
648
|
-
f"DISPLAY={self._display} gtk-launch {application} {uri or ''}",
|
|
649
|
-
background=True,
|
|
650
|
-
timeout=0,
|
|
651
|
-
)
|
|
1
|
+
import time
|
|
2
|
+
from re import search as re_search
|
|
3
|
+
from shlex import quote as quote_string
|
|
4
|
+
from typing import Callable, Dict, Iterator, Literal, Optional, Tuple, Union, overload
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from httpx._types import ProxyTypes
|
|
8
|
+
|
|
9
|
+
from ..exceptions import TimeoutException
|
|
10
|
+
from ..sandbox_sync import CommandExitException, CommandHandle, CommandResult
|
|
11
|
+
from ..sandbox_sync import Sandbox as SandboxBase
|
|
12
|
+
|
|
13
|
+
MOUSE_BUTTONS = {"left": 1, "right": 3, "middle": 2}
|
|
14
|
+
|
|
15
|
+
KEYS = {
|
|
16
|
+
"alt": "Alt_L",
|
|
17
|
+
"alt_left": "Alt_L",
|
|
18
|
+
"alt_right": "Alt_R",
|
|
19
|
+
"backspace": "BackSpace",
|
|
20
|
+
"break": "Pause",
|
|
21
|
+
"caps_lock": "Caps_Lock",
|
|
22
|
+
"cmd": "Super_L",
|
|
23
|
+
"command": "Super_L",
|
|
24
|
+
"control": "Control_L",
|
|
25
|
+
"control_left": "Control_L",
|
|
26
|
+
"control_right": "Control_R",
|
|
27
|
+
"ctrl": "Control_L",
|
|
28
|
+
"del": "Delete",
|
|
29
|
+
"delete": "Delete",
|
|
30
|
+
"down": "Down",
|
|
31
|
+
"end": "End",
|
|
32
|
+
"enter": "Return",
|
|
33
|
+
"esc": "Escape",
|
|
34
|
+
"escape": "Escape",
|
|
35
|
+
"f1": "F1",
|
|
36
|
+
"f2": "F2",
|
|
37
|
+
"f3": "F3",
|
|
38
|
+
"f4": "F4",
|
|
39
|
+
"f5": "F5",
|
|
40
|
+
"f6": "F6",
|
|
41
|
+
"f7": "F7",
|
|
42
|
+
"f8": "F8",
|
|
43
|
+
"f9": "F9",
|
|
44
|
+
"f10": "F10",
|
|
45
|
+
"f11": "F11",
|
|
46
|
+
"f12": "F12",
|
|
47
|
+
"home": "Home",
|
|
48
|
+
"insert": "Insert",
|
|
49
|
+
"left": "Left",
|
|
50
|
+
"menu": "Menu",
|
|
51
|
+
"meta": "Meta_L",
|
|
52
|
+
"num_lock": "Num_Lock",
|
|
53
|
+
"page_down": "Page_Down",
|
|
54
|
+
"page_up": "Page_Up",
|
|
55
|
+
"pause": "Pause",
|
|
56
|
+
"print": "Print",
|
|
57
|
+
"right": "Right",
|
|
58
|
+
"scroll_lock": "Scroll_Lock",
|
|
59
|
+
"shift": "Shift_L",
|
|
60
|
+
"shift_left": "Shift_L",
|
|
61
|
+
"shift_right": "Shift_R",
|
|
62
|
+
"space": "space",
|
|
63
|
+
"super": "Super_L",
|
|
64
|
+
"super_left": "Super_L",
|
|
65
|
+
"super_right": "Super_R",
|
|
66
|
+
"tab": "Tab",
|
|
67
|
+
"up": "Up",
|
|
68
|
+
"win": "Super_L",
|
|
69
|
+
"windows": "Super_L",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def map_key(key: str) -> str:
|
|
74
|
+
lower_key = key.lower()
|
|
75
|
+
if lower_key in KEYS:
|
|
76
|
+
return KEYS[lower_key]
|
|
77
|
+
return lower_key
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _VNCServer:
|
|
81
|
+
def __init__(self, desktop: "Sandbox") -> None:
|
|
82
|
+
self.__novnc_handle: Optional[CommandHandle] = None
|
|
83
|
+
|
|
84
|
+
self._vnc_port = 5900
|
|
85
|
+
self._port = 6080
|
|
86
|
+
self._novnc_auth_enabled = False
|
|
87
|
+
self._novnc_password = None
|
|
88
|
+
|
|
89
|
+
self._url = f"https://{self._port}-{desktop.sandbox_domain}/vnc.html"
|
|
90
|
+
# self._url=""
|
|
91
|
+
|
|
92
|
+
self.__desktop = desktop
|
|
93
|
+
|
|
94
|
+
def _wait_for_port(self, port: int) -> bool:
|
|
95
|
+
return self.__desktop._wait_and_verify(
|
|
96
|
+
f'netstat -tuln | grep ":{port} "', lambda r: r.stdout.strip() != ""
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def _check_vnc_running(self) -> bool:
|
|
100
|
+
try:
|
|
101
|
+
self.__desktop.commands.run("pgrep -x x11vnc")
|
|
102
|
+
return True
|
|
103
|
+
except CommandExitException:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _generate_password(length: int = 16) -> str:
|
|
108
|
+
import secrets
|
|
109
|
+
import string
|
|
110
|
+
|
|
111
|
+
characters = string.ascii_letters + string.digits
|
|
112
|
+
return "".join(secrets.choice(characters) for _ in range(length))
|
|
113
|
+
|
|
114
|
+
def get_url(
|
|
115
|
+
self,
|
|
116
|
+
auto_connect: bool = True,
|
|
117
|
+
view_only: bool = False,
|
|
118
|
+
resize: str = "scale",
|
|
119
|
+
auth_key: Optional[str] = None,
|
|
120
|
+
) -> str:
|
|
121
|
+
params = []
|
|
122
|
+
if auto_connect:
|
|
123
|
+
params.append("autoconnect=true")
|
|
124
|
+
if view_only:
|
|
125
|
+
params.append(f"view_only=true")
|
|
126
|
+
if resize:
|
|
127
|
+
params.append(f"resize={resize}")
|
|
128
|
+
if auth_key:
|
|
129
|
+
params.append(f"password={auth_key}")
|
|
130
|
+
if params:
|
|
131
|
+
return f"{self._url}?{'&'.join(params)}"
|
|
132
|
+
return self._url
|
|
133
|
+
|
|
134
|
+
def get_auth_key(self) -> str:
|
|
135
|
+
if not self._novnc_password:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"Unable to retrieve stream auth key, check if require_auth is enabled"
|
|
138
|
+
)
|
|
139
|
+
return self._novnc_password
|
|
140
|
+
|
|
141
|
+
def start(
|
|
142
|
+
self,
|
|
143
|
+
vnc_port: Optional[int] = None,
|
|
144
|
+
port: Optional[int] = None,
|
|
145
|
+
require_auth: bool = False,
|
|
146
|
+
window_id: Optional[str] = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
# If stream is already running, throw an error
|
|
149
|
+
if self._check_vnc_running():
|
|
150
|
+
raise RuntimeError("Stream is already running")
|
|
151
|
+
|
|
152
|
+
# Update parameters if provided
|
|
153
|
+
self._vnc_port = vnc_port or self._vnc_port
|
|
154
|
+
self._port = port or self._port
|
|
155
|
+
self._novnc_auth_enabled = require_auth or self._novnc_auth_enabled
|
|
156
|
+
self._novnc_password = self._generate_password() if require_auth else None
|
|
157
|
+
|
|
158
|
+
# Update URL with new port
|
|
159
|
+
# self._url = ""
|
|
160
|
+
# self._url = f"https://{self.__desktop.get_host(self._port)}/vnc.html"
|
|
161
|
+
|
|
162
|
+
# Set up VNC command
|
|
163
|
+
pwd_flag = "-nopw"
|
|
164
|
+
if self._novnc_auth_enabled:
|
|
165
|
+
self.__desktop.commands.run("mkdir -p ~/.vnc")
|
|
166
|
+
self.__desktop.commands.run(
|
|
167
|
+
f"x11vnc -storepasswd {self._novnc_password} ~/.vnc/passwd"
|
|
168
|
+
)
|
|
169
|
+
pwd_flag = "-usepw"
|
|
170
|
+
window_id_flag = ""
|
|
171
|
+
if window_id:
|
|
172
|
+
window_id_flag = f"-id {window_id}"
|
|
173
|
+
|
|
174
|
+
vnc_command = (
|
|
175
|
+
f"DISPLAY={self.__desktop._display} x11vnc -bg -display {self.__desktop._display} -forever -wait 50 -shared "
|
|
176
|
+
f"-rfbport {self._vnc_port} {pwd_flag} 2>/tmp/x11vnc_stderr.log {window_id_flag}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
novnc_command = (
|
|
180
|
+
f"cd /opt/noVNC/utils && ./novnc_proxy --vnc localhost:{self._vnc_port} "
|
|
181
|
+
f"--listen {self._port} --web /opt/noVNC > /tmp/novnc.log 2>&1 &"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self.__desktop.commands.run(vnc_command)
|
|
185
|
+
self.__novnc_handle = self.__desktop.commands.run(
|
|
186
|
+
novnc_command, background=True, timeout=0
|
|
187
|
+
)
|
|
188
|
+
if not self._wait_for_port(self._port):
|
|
189
|
+
raise TimeoutException("Could not start noVNC server")
|
|
190
|
+
|
|
191
|
+
def stop(self) -> None:
|
|
192
|
+
if self._check_vnc_running():
|
|
193
|
+
self.__desktop.commands.run("pkill x11vnc")
|
|
194
|
+
|
|
195
|
+
if self.__novnc_handle:
|
|
196
|
+
self.__novnc_handle.kill()
|
|
197
|
+
self.__novnc_handle = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class Sandbox(SandboxBase):
|
|
201
|
+
default_template = "desktop"
|
|
202
|
+
|
|
203
|
+
# def __init__(
|
|
204
|
+
# self,
|
|
205
|
+
# resolution: Optional[Tuple[int, int]] = None,
|
|
206
|
+
# dpi: Optional[int] = None,
|
|
207
|
+
# display: Optional[str] = None,
|
|
208
|
+
# template: Optional[str] = None,
|
|
209
|
+
# timeout: Optional[int] = None,
|
|
210
|
+
# metadata: Optional[Dict[str, str]] = None,
|
|
211
|
+
# envs: Optional[Dict[str, str]] = None,
|
|
212
|
+
# api_key: Optional[str] = None,
|
|
213
|
+
# domain: Optional[str] = None,
|
|
214
|
+
# debug: Optional[bool] = None,
|
|
215
|
+
# sandbox_id: Optional[str] = None,
|
|
216
|
+
# request_timeout: Optional[float] = None,
|
|
217
|
+
# proxy: Optional[ProxyTypes] = None,
|
|
218
|
+
# ):
|
|
219
|
+
# """
|
|
220
|
+
# Create a new desktop sandbox.
|
|
221
|
+
#
|
|
222
|
+
# By default, the sandbox is created from the `desktop` template.
|
|
223
|
+
#
|
|
224
|
+
# :param resolution: Startup the desktop with custom screen resolution. Defaults to (1024, 768)
|
|
225
|
+
# :param dpi: Startup the desktop with custom DPI. Defaults to 96
|
|
226
|
+
# :param display: Startup the desktop with custom display. Defaults to ":0"
|
|
227
|
+
# :param template: Sandbox template name or ID
|
|
228
|
+
# :param timeout: Timeout for the sandbox in **seconds**, default to 300 seconds. Maximum time a sandbox can be kept alive is 24 hours (86_400 seconds) for Pro users and 1 hour (3_600 seconds) for Hobby users
|
|
229
|
+
# :param metadata: Custom metadata for the sandbox
|
|
230
|
+
# :param envs: Custom environment variables for the sandbox
|
|
231
|
+
# :param api_key: API Key to use for authentication, defaults to `SBX_API_KEY` environment variable
|
|
232
|
+
# :param domain: Domain to use for authentication, defaults to `SBX_DOMAIN` environment variable
|
|
233
|
+
# :param debug: If True, the sandbox will be created in debug mode, defaults to `SBX_DEBUG` environment variable
|
|
234
|
+
# :param sandbox_id: Sandbox ID to connect to, defaults to `SBX_SANDBOX_ID` environment variable
|
|
235
|
+
# :param request_timeout: Timeout for the request in **seconds**
|
|
236
|
+
# :param proxy: Proxy to use for the request and for the requests made to the returned sandbox
|
|
237
|
+
#
|
|
238
|
+
# :return: sandbox instance for the new sandbox
|
|
239
|
+
# """
|
|
240
|
+
# self._display = display or ":0"
|
|
241
|
+
# self._last_xfce4_pid = None
|
|
242
|
+
#
|
|
243
|
+
# # Initialize environment variables with DISPLAY
|
|
244
|
+
# if envs is None:
|
|
245
|
+
# envs = {}
|
|
246
|
+
# envs["DISPLAY"] = self._display
|
|
247
|
+
#
|
|
248
|
+
# _base = SandboxBase.create(
|
|
249
|
+
# template=template,
|
|
250
|
+
# timeout=timeout,
|
|
251
|
+
# metadata=metadata,
|
|
252
|
+
# envs=envs,
|
|
253
|
+
# api_key=api_key,
|
|
254
|
+
# domain=domain,
|
|
255
|
+
# debug=debug,
|
|
256
|
+
# sandbox_id=sandbox_id,
|
|
257
|
+
# request_timeout=request_timeout,
|
|
258
|
+
# proxy=proxy,
|
|
259
|
+
# )
|
|
260
|
+
# self.__dict__.update(_base.__dict__)
|
|
261
|
+
#
|
|
262
|
+
# # Only initialize desktop environment if we're not just connecting to an existing sandbox
|
|
263
|
+
# print("--------------"+sandbox_id)
|
|
264
|
+
# if not sandbox_id:
|
|
265
|
+
# width, height = resolution or (1024, 768)
|
|
266
|
+
#
|
|
267
|
+
# # self.commands.run(
|
|
268
|
+
# # f"Xvfb {self._display} -ac -screen 0 {width}x{height}x24"
|
|
269
|
+
# # f" -retro -dpi {dpi or 96} -nolisten tcp -nolisten unix",
|
|
270
|
+
# # background=True,
|
|
271
|
+
# # timeout=0,
|
|
272
|
+
# # )
|
|
273
|
+
# self.commands.run(
|
|
274
|
+
# f"Xvfb {self._display} -ac -screen 0 {width}x{height}x24"
|
|
275
|
+
# f" -retro -dpi {dpi or 96} -nolisten tcp -nolisten unix &",
|
|
276
|
+
# background=True,
|
|
277
|
+
# timeout=0,
|
|
278
|
+
# )
|
|
279
|
+
# print("11111111111")
|
|
280
|
+
# if not self._wait_and_verify(
|
|
281
|
+
# f"xdpyinfo -display {self._display}", lambda r: r.exit_code == 0
|
|
282
|
+
# ):
|
|
283
|
+
# raise TimeoutException("Could not start Xvfb")
|
|
284
|
+
# print("2222222222")
|
|
285
|
+
# self.__vnc_server = _VNCServer(self)
|
|
286
|
+
# self._start_xfce4()
|
|
287
|
+
# print("333333333333")
|
|
288
|
+
# else:
|
|
289
|
+
# # When connecting to existing sandbox, just initialize VNC server
|
|
290
|
+
# self.__vnc_server = _VNCServer(self)
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def create(
|
|
294
|
+
cls,
|
|
295
|
+
resolution: Optional[Tuple[int, int]] = None,
|
|
296
|
+
dpi: Optional[int] = None,
|
|
297
|
+
display: Optional[str] = None,
|
|
298
|
+
template: Optional[str] = None,
|
|
299
|
+
timeout: Optional[int] = None,
|
|
300
|
+
metadata: Optional[Dict[str, str]] = None,
|
|
301
|
+
envs: Optional[Dict[str, str]] = None,
|
|
302
|
+
api_key: Optional[str] = None,
|
|
303
|
+
domain: Optional[str] = None,
|
|
304
|
+
debug: Optional[bool] = None,
|
|
305
|
+
sandbox_id: Optional[str] = None,
|
|
306
|
+
request_timeout: Optional[float] = None,
|
|
307
|
+
proxy: Optional[ProxyTypes] = None,
|
|
308
|
+
) -> "Sandbox":
|
|
309
|
+
"""
|
|
310
|
+
Synchronously create or connect to a desktop sandbox.
|
|
311
|
+
|
|
312
|
+
Parameters have the same meaning as the base class. Xvfb + xfce4 will be automatically started on first creation.
|
|
313
|
+
"""
|
|
314
|
+
display = display or ":0"
|
|
315
|
+
if envs is None:
|
|
316
|
+
envs = {}
|
|
317
|
+
envs["DISPLAY"] = display
|
|
318
|
+
|
|
319
|
+
# 1. Let the parent class factory actually create / reuse
|
|
320
|
+
base = SandboxBase.create(
|
|
321
|
+
template=template or cls.default_template,
|
|
322
|
+
timeout=timeout,
|
|
323
|
+
metadata=metadata,
|
|
324
|
+
envs=envs,
|
|
325
|
+
api_key=api_key,
|
|
326
|
+
domain=domain,
|
|
327
|
+
debug=debug,
|
|
328
|
+
sandbox_id=sandbox_id,
|
|
329
|
+
request_timeout=request_timeout,
|
|
330
|
+
proxy=proxy,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# 2. Dynamically upgrade type to Sandbox
|
|
334
|
+
base.__class__ = cls
|
|
335
|
+
base._display = display
|
|
336
|
+
base._last_xfce4_pid = None
|
|
337
|
+
|
|
338
|
+
# 3. Only start desktop on first creation
|
|
339
|
+
if not sandbox_id:
|
|
340
|
+
base._start_desktop(resolution, dpi)
|
|
341
|
+
else:
|
|
342
|
+
# Connect to existing sandbox, only initialize VNC
|
|
343
|
+
base.__vnc_server = _VNCServer(base)
|
|
344
|
+
|
|
345
|
+
return base
|
|
346
|
+
|
|
347
|
+
# ====================== Subclass Private Methods ======================
|
|
348
|
+
def _start_desktop(
|
|
349
|
+
self,
|
|
350
|
+
resolution: Optional[Tuple[int, int]] = None,
|
|
351
|
+
dpi: Optional[int] = None,
|
|
352
|
+
) -> None:
|
|
353
|
+
"""Start Xvfb and wait for success, then start xfce4 + VNC."""
|
|
354
|
+
width, height = resolution or (1024, 768)
|
|
355
|
+
self.commands.run(
|
|
356
|
+
f"Xvfb {self._display} -ac -screen 0 {width}x{height}x24 "
|
|
357
|
+
f"-retro -dpi {dpi or 96} -nolisten tcp -nolisten unix",
|
|
358
|
+
background=True,
|
|
359
|
+
timeout=0,
|
|
360
|
+
)
|
|
361
|
+
if not self._wait_and_verify(
|
|
362
|
+
f"xdpyinfo -display {self._display}",
|
|
363
|
+
lambda r: r.exit_code == 0,
|
|
364
|
+
):
|
|
365
|
+
raise TimeoutException("Could not start Xvfb")
|
|
366
|
+
self.__vnc_server = _VNCServer(self)
|
|
367
|
+
self._start_xfce4()
|
|
368
|
+
|
|
369
|
+
def _wait_and_verify(
|
|
370
|
+
self,
|
|
371
|
+
cmd: str,
|
|
372
|
+
on_result: Callable[[CommandResult], bool],
|
|
373
|
+
timeout: int = 10,
|
|
374
|
+
interval: float = 0.5,
|
|
375
|
+
) -> bool:
|
|
376
|
+
|
|
377
|
+
elapsed = 0
|
|
378
|
+
while elapsed < timeout:
|
|
379
|
+
try:
|
|
380
|
+
if on_result(self.commands.run(cmd)):
|
|
381
|
+
return True
|
|
382
|
+
except CommandExitException:
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
time.sleep(interval)
|
|
386
|
+
elapsed += interval
|
|
387
|
+
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def _start_xfce4(self):
|
|
391
|
+
"""
|
|
392
|
+
Start xfce4 session if logged out or not running.
|
|
393
|
+
"""
|
|
394
|
+
if self._last_xfce4_pid is None or "[xfce4-session] <defunct>" in (
|
|
395
|
+
self.commands.run(
|
|
396
|
+
f"ps aux | grep {self._last_xfce4_pid} | grep -v grep | head -n 1"
|
|
397
|
+
).stdout.strip()
|
|
398
|
+
):
|
|
399
|
+
self._last_xfce4_pid = self.commands.run(
|
|
400
|
+
f"mkdir -p $HOME/.config $HOME/.cache $HOME/.dbus && DISPLAY={self._display} startxfce4",
|
|
401
|
+
background=True,
|
|
402
|
+
timeout=0,
|
|
403
|
+
envs={"HOME": "/workspace"},
|
|
404
|
+
).pid
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def stream(self) -> _VNCServer:
|
|
408
|
+
return self.__vnc_server
|
|
409
|
+
|
|
410
|
+
@overload
|
|
411
|
+
def screenshot(self, format: Literal["stream"]) -> Iterator[bytes]:
|
|
412
|
+
"""
|
|
413
|
+
Take a screenshot and return it as a stream of bytes.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
@overload
|
|
417
|
+
def screenshot(
|
|
418
|
+
self,
|
|
419
|
+
format: Literal["bytes"],
|
|
420
|
+
) -> bytearray:
|
|
421
|
+
"""
|
|
422
|
+
Take a screenshot and return it as a bytearray.
|
|
423
|
+
"""
|
|
424
|
+
|
|
425
|
+
def screenshot(
|
|
426
|
+
self,
|
|
427
|
+
format: Literal["bytes", "stream"] = "bytes",
|
|
428
|
+
):
|
|
429
|
+
"""
|
|
430
|
+
Take a screenshot and return it in the specified format.
|
|
431
|
+
|
|
432
|
+
:param format: The format of the screenshot. Can be 'bytes', 'blob', or 'stream'.
|
|
433
|
+
:returns: The screenshot in the specified format.
|
|
434
|
+
"""
|
|
435
|
+
screenshot_path = f"/tmp/screenshot-{uuid4()}.png"
|
|
436
|
+
|
|
437
|
+
self.commands.run(f"DISPLAY={self._display} scrot --pointer {screenshot_path}")
|
|
438
|
+
|
|
439
|
+
file = self.files.read(screenshot_path, format=format)
|
|
440
|
+
self.files.remove(screenshot_path)
|
|
441
|
+
return file
|
|
442
|
+
|
|
443
|
+
def left_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
444
|
+
"""
|
|
445
|
+
Left click on the mouse position.
|
|
446
|
+
"""
|
|
447
|
+
if x and y:
|
|
448
|
+
self.move_mouse(x, y)
|
|
449
|
+
self.commands.run(f"DISPLAY={self._display} xdotool click 1")
|
|
450
|
+
|
|
451
|
+
def double_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
452
|
+
"""
|
|
453
|
+
Double left click on the mouse position.
|
|
454
|
+
"""
|
|
455
|
+
if x and y:
|
|
456
|
+
self.move_mouse(x, y)
|
|
457
|
+
self.commands.run(f"DISPLAY={self._display} xdotool click --repeat 2 1")
|
|
458
|
+
|
|
459
|
+
def right_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
460
|
+
if (x is None) != (y is None):
|
|
461
|
+
raise ValueError("Both x and y must be provided together")
|
|
462
|
+
"""
|
|
463
|
+
Right click on the mouse position.
|
|
464
|
+
"""
|
|
465
|
+
if x and y:
|
|
466
|
+
self.move_mouse(x, y)
|
|
467
|
+
self.commands.run(f"DISPLAY={self._display} xdotool click 3")
|
|
468
|
+
|
|
469
|
+
def middle_click(self, x: Optional[int] = None, y: Optional[int] = None):
|
|
470
|
+
"""
|
|
471
|
+
Middle click on the mouse position.
|
|
472
|
+
"""
|
|
473
|
+
if x and y:
|
|
474
|
+
self.move_mouse(x, y)
|
|
475
|
+
self.commands.run(f"DISPLAY={self._display} xdotool click 2")
|
|
476
|
+
|
|
477
|
+
def scroll(self, direction: Literal["up", "down"] = "down", amount: int = 1):
|
|
478
|
+
"""
|
|
479
|
+
Scroll the mouse wheel by the given amount.
|
|
480
|
+
|
|
481
|
+
:param direction: The direction to scroll. Can be "up" or "down".
|
|
482
|
+
:param amount: The amount to scroll.
|
|
483
|
+
"""
|
|
484
|
+
self.commands.run(
|
|
485
|
+
f"DISPLAY={self._display} xdotool click --repeat {amount} {'4' if direction == 'up' else '5'}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def move_mouse(self, x: int, y: int):
|
|
489
|
+
"""
|
|
490
|
+
Move the mouse to the given coordinates.
|
|
491
|
+
|
|
492
|
+
:param x: The x coordinate.
|
|
493
|
+
:param y: The y coordinate.
|
|
494
|
+
"""
|
|
495
|
+
self.commands.run(f"DISPLAY={self._display} xdotool mousemove --sync {x} {y}")
|
|
496
|
+
|
|
497
|
+
def mouse_press(self, button: Literal["left", "right", "middle"] = "left"):
|
|
498
|
+
"""
|
|
499
|
+
Press the mouse button.
|
|
500
|
+
"""
|
|
501
|
+
self.commands.run(
|
|
502
|
+
f"DISPLAY={self._display} xdotool mousedown {MOUSE_BUTTONS[button]}"
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
def mouse_release(self, button: Literal["left", "right", "middle"] = "left"):
|
|
506
|
+
"""
|
|
507
|
+
Release the mouse button.
|
|
508
|
+
"""
|
|
509
|
+
self.commands.run(
|
|
510
|
+
f"DISPLAY={self._display} xdotool mouseup {MOUSE_BUTTONS[button]}"
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
def get_cursor_position(self) -> tuple[int, int]:
|
|
514
|
+
"""
|
|
515
|
+
Get the current cursor position.
|
|
516
|
+
|
|
517
|
+
:return: A tuple with the x and y coordinates
|
|
518
|
+
:raises RuntimeError: If the cursor position cannot be determined
|
|
519
|
+
"""
|
|
520
|
+
result = self.commands.run(f"DISPLAY={self._display} xdotool getmouselocation")
|
|
521
|
+
|
|
522
|
+
groups = re_search(r"x:(\d+)\s+y:(\d+)", result.stdout)
|
|
523
|
+
if not groups:
|
|
524
|
+
raise RuntimeError(
|
|
525
|
+
f"Failed to parse cursor position from output: {result.stdout}"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
x, y = groups.group(1), groups.group(2)
|
|
529
|
+
if not x or not y:
|
|
530
|
+
raise RuntimeError(f"Invalid cursor position values: x={x}, y={y}")
|
|
531
|
+
|
|
532
|
+
return int(x), int(y)
|
|
533
|
+
|
|
534
|
+
def get_screen_size(self) -> tuple[int, int]:
|
|
535
|
+
"""
|
|
536
|
+
Get the current screen size.
|
|
537
|
+
|
|
538
|
+
:return: A tuple with the width and height
|
|
539
|
+
:raises RuntimeError: If the screen size cannot be determined
|
|
540
|
+
"""
|
|
541
|
+
result = self.commands.run(f"DISPLAY={self._display} xrandr")
|
|
542
|
+
|
|
543
|
+
_match = re_search(r"(\d+x\d+)", result.stdout)
|
|
544
|
+
if not _match:
|
|
545
|
+
raise RuntimeError(
|
|
546
|
+
f"Failed to parse screen size from output: {result.stdout}"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
return tuple(map(int, _match.group(1).split("x"))) # type: ignore
|
|
551
|
+
except (ValueError, IndexError) as e:
|
|
552
|
+
raise RuntimeError(f"Invalid screen size format: {_match.group(1)}") from e
|
|
553
|
+
|
|
554
|
+
def write(self, text: str, *, chunk_size: int = 25, delay_in_ms: int = 75) -> None:
|
|
555
|
+
"""
|
|
556
|
+
Write the given text at the current cursor position.
|
|
557
|
+
|
|
558
|
+
:param text: The text to write.
|
|
559
|
+
:param chunk_size: The size of each chunk of text to write.
|
|
560
|
+
:param delay_in_ms: The delay between each chunk of text.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
def break_into_chunks(text: str, n: int):
|
|
564
|
+
for i in range(0, len(text), n):
|
|
565
|
+
yield text[i : i + n]
|
|
566
|
+
|
|
567
|
+
for chunk in break_into_chunks(text, chunk_size):
|
|
568
|
+
self.commands.run(
|
|
569
|
+
f"DISPLAY={self._display} xdotool type --delay {delay_in_ms} {quote_string(chunk)}"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
def press(self, key: Union[str, list[str]]):
|
|
573
|
+
"""
|
|
574
|
+
Press a key.
|
|
575
|
+
|
|
576
|
+
:param key: The key to press (e.g. "enter", "space", "backspace", etc.).
|
|
577
|
+
"""
|
|
578
|
+
if isinstance(key, list):
|
|
579
|
+
key = "+".join(map_key(k) for k in key)
|
|
580
|
+
else:
|
|
581
|
+
key = map_key(key)
|
|
582
|
+
|
|
583
|
+
self.commands.run(f"DISPLAY={self._display} xdotool key {key}")
|
|
584
|
+
|
|
585
|
+
def drag(self, fr: tuple[int, int], to: tuple[int, int]):
|
|
586
|
+
"""
|
|
587
|
+
Drag the mouse from the given position to the given position.
|
|
588
|
+
|
|
589
|
+
:param from: The starting position.
|
|
590
|
+
:param to: The ending position.
|
|
591
|
+
"""
|
|
592
|
+
self.move_mouse(fr[0], fr[1])
|
|
593
|
+
self.mouse_press()
|
|
594
|
+
self.move_mouse(to[0], to[1])
|
|
595
|
+
self.mouse_release()
|
|
596
|
+
|
|
597
|
+
def wait(self, ms: int):
|
|
598
|
+
"""
|
|
599
|
+
Wait for the given amount of time.
|
|
600
|
+
|
|
601
|
+
:param ms: The amount of time to wait in milliseconds.
|
|
602
|
+
"""
|
|
603
|
+
self.commands.run(f"sleep {ms / 1000}")
|
|
604
|
+
|
|
605
|
+
def open(self, file_or_url: str):
|
|
606
|
+
"""
|
|
607
|
+
Open a file or a URL in the default application.
|
|
608
|
+
|
|
609
|
+
:param file_or_url: The file or URL to open.
|
|
610
|
+
"""
|
|
611
|
+
self.commands.run(
|
|
612
|
+
f"DISPLAY={self._display} xdg-open {file_or_url}", background=True
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def get_current_window_id(self) -> str:
|
|
616
|
+
"""
|
|
617
|
+
Get the current window ID.
|
|
618
|
+
"""
|
|
619
|
+
return self.commands.run(
|
|
620
|
+
f"DISPLAY={self._display} xdotool getwindowfocus"
|
|
621
|
+
).stdout.strip()
|
|
622
|
+
|
|
623
|
+
def get_application_windows(self, application: str) -> list[str]:
|
|
624
|
+
"""
|
|
625
|
+
Get the window IDs of all windows for the given application.
|
|
626
|
+
"""
|
|
627
|
+
return (
|
|
628
|
+
self.commands.run(
|
|
629
|
+
f"DISPLAY={self._display} xdotool search --onlyvisible --class {application}"
|
|
630
|
+
)
|
|
631
|
+
.stdout.strip()
|
|
632
|
+
.split("\n")
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def get_window_title(self, window_id: str) -> str:
|
|
636
|
+
"""
|
|
637
|
+
Get the title of the window with the given ID.
|
|
638
|
+
"""
|
|
639
|
+
return self.commands.run(
|
|
640
|
+
f"DISPLAY={self._display} xdotool getwindowname {window_id}"
|
|
641
|
+
).stdout.strip()
|
|
642
|
+
|
|
643
|
+
def launch(self, application: str, uri: Optional[str] = None):
|
|
644
|
+
"""
|
|
645
|
+
Launch an application.
|
|
646
|
+
"""
|
|
647
|
+
self.commands.run(
|
|
648
|
+
f"DISPLAY={self._display} gtk-launch {application} {uri or ''}",
|
|
649
|
+
background=True,
|
|
650
|
+
timeout=0,
|
|
651
|
+
)
|