use-computer-cli 0.1.0__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.
- use_computer/__init__.py +93 -0
- use_computer/actions.py +184 -0
- use_computer/backends/__init__.py +31 -0
- use_computer/backends/base.py +66 -0
- use_computer/backends/local.py +214 -0
- use_computer/backends/vnc.py +186 -0
- use_computer/cli.py +676 -0
- use_computer/compare.py +121 -0
- use_computer/config.py +532 -0
- use_computer/coordinates.py +104 -0
- use_computer/errors.py +53 -0
- use_computer/keys.py +209 -0
- use_computer/runner.py +304 -0
- use_computer/skill/SKILL.md +118 -0
- use_computer/skill/__init__.py +185 -0
- use_computer_cli-0.1.0.dist-info/METADATA +150 -0
- use_computer_cli-0.1.0.dist-info/RECORD +20 -0
- use_computer_cli-0.1.0.dist-info/WHEEL +4 -0
- use_computer_cli-0.1.0.dist-info/entry_points.txt +2 -0
- use_computer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""The VNC backend: drives a remote framebuffer over RFB, through vncdotool.
|
|
2
|
+
|
|
3
|
+
Opening the connection dominates the cost of a single action, which is why a run performs a
|
|
4
|
+
whole batch over one connection. This class holds that connection open until it is closed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from use_computer.actions import MouseButton, ScrollDirection
|
|
16
|
+
from use_computer.backends.base import require
|
|
17
|
+
from use_computer.compare import Screenshot
|
|
18
|
+
from use_computer.coordinates import CoordinateSpace, ScreenInfo
|
|
19
|
+
from use_computer.errors import ActionFailedError, ConfigError
|
|
20
|
+
from use_computer.keys import VNC_KEYS, KeyCombo
|
|
21
|
+
|
|
22
|
+
#: RFB button numbers. 4/5 are wheel up/down, 6/7 wheel left/right.
|
|
23
|
+
_BUTTONS = {MouseButton.LEFT: 1, MouseButton.MIDDLE: 2, MouseButton.RIGHT: 3}
|
|
24
|
+
_WHEEL = {
|
|
25
|
+
ScrollDirection.UP: 4,
|
|
26
|
+
ScrollDirection.DOWN: 5,
|
|
27
|
+
ScrollDirection.LEFT: 6,
|
|
28
|
+
ScrollDirection.RIGHT: 7,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
DRAG_STEPS = 24
|
|
32
|
+
STEP_PAUSE = 0.01
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class VNCBackend:
|
|
36
|
+
"""Drives a remote screen over RFB."""
|
|
37
|
+
|
|
38
|
+
name = "vnc"
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
host: str | None,
|
|
44
|
+
port: int = 5900,
|
|
45
|
+
password: str | None = None,
|
|
46
|
+
scale: float | None = None,
|
|
47
|
+
client: Any | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
if client is None and not host:
|
|
50
|
+
raise ConfigError(
|
|
51
|
+
"the vnc backend needs a host: set `host` in the profile, or "
|
|
52
|
+
"USE_COMPUTER_PROFILES__<PROFILE>__HOST."
|
|
53
|
+
)
|
|
54
|
+
self._explicit_scale = scale
|
|
55
|
+
self._screen: ScreenInfo | None = None
|
|
56
|
+
if client is not None:
|
|
57
|
+
self._client = client
|
|
58
|
+
self._api = None
|
|
59
|
+
return
|
|
60
|
+
api = require("vncdotool.api", backend=self.name, extra="vnc")
|
|
61
|
+
self._api = api
|
|
62
|
+
# vncdotool addresses a server as host::port.
|
|
63
|
+
self._client = api.connect(f"{host}::{port}", password=password)
|
|
64
|
+
|
|
65
|
+
# --- reporting ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def screen_info(self) -> ScreenInfo:
|
|
68
|
+
if self._screen is not None:
|
|
69
|
+
return self._screen
|
|
70
|
+
shot = self.screenshot()
|
|
71
|
+
# A framebuffer has one coordinate space: what is captured is what is clicked.
|
|
72
|
+
self._screen = ScreenInfo(
|
|
73
|
+
width=shot.width,
|
|
74
|
+
height=shot.height,
|
|
75
|
+
screenshot_width=shot.width,
|
|
76
|
+
screenshot_height=shot.height,
|
|
77
|
+
scale=self._explicit_scale if self._explicit_scale is not None else 1.0,
|
|
78
|
+
)
|
|
79
|
+
return self._screen
|
|
80
|
+
|
|
81
|
+
def screenshot(self) -> Screenshot:
|
|
82
|
+
with tempfile.TemporaryDirectory(prefix="use-computer-") as tmp:
|
|
83
|
+
path = Path(tmp) / "screen.png"
|
|
84
|
+
self._client.captureScreen(str(path))
|
|
85
|
+
data = path.read_bytes()
|
|
86
|
+
with _open(data) as image:
|
|
87
|
+
width, height = image.size
|
|
88
|
+
return Screenshot(
|
|
89
|
+
data=data, width=width, height=height, space=CoordinateSpace.SCREENSHOT
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# --- acting ------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
def move(self, x: int, y: int) -> None:
|
|
95
|
+
self._client.mouseMove(x, y)
|
|
96
|
+
|
|
97
|
+
def click(self, x: int | None, y: int | None, button: MouseButton, count: int) -> None:
|
|
98
|
+
if x is not None and y is not None:
|
|
99
|
+
self.move(x, y)
|
|
100
|
+
number = _BUTTONS[button]
|
|
101
|
+
for index in range(count):
|
|
102
|
+
if index:
|
|
103
|
+
time.sleep(STEP_PAUSE)
|
|
104
|
+
self._client.mousePress(number)
|
|
105
|
+
|
|
106
|
+
def drag(self, from_x: int, from_y: int, to_x: int, to_y: int, button: MouseButton) -> None:
|
|
107
|
+
number = _BUTTONS[button]
|
|
108
|
+
self.move(from_x, from_y)
|
|
109
|
+
self._client.mouseDown(number)
|
|
110
|
+
try:
|
|
111
|
+
for step in range(1, DRAG_STEPS + 1):
|
|
112
|
+
ratio = step / DRAG_STEPS
|
|
113
|
+
self.move(
|
|
114
|
+
round(from_x + (to_x - from_x) * ratio),
|
|
115
|
+
round(from_y + (to_y - from_y) * ratio),
|
|
116
|
+
)
|
|
117
|
+
time.sleep(STEP_PAUSE)
|
|
118
|
+
finally:
|
|
119
|
+
self._client.mouseUp(number)
|
|
120
|
+
|
|
121
|
+
def scroll(
|
|
122
|
+
self, amount: int, direction: ScrollDirection, x: int | None, y: int | None
|
|
123
|
+
) -> None:
|
|
124
|
+
if x is not None and y is not None:
|
|
125
|
+
self.move(x, y)
|
|
126
|
+
number = _WHEEL[direction]
|
|
127
|
+
for _ in range(max(1, abs(amount))):
|
|
128
|
+
self._client.mousePress(number)
|
|
129
|
+
time.sleep(STEP_PAUSE)
|
|
130
|
+
|
|
131
|
+
def type_text(self, text: str, rate: float) -> None:
|
|
132
|
+
# vncdotool grew a `type` helper; where it is missing, one keyPress per character does
|
|
133
|
+
# the same thing at the same rate.
|
|
134
|
+
typer = getattr(self._client, "type", None)
|
|
135
|
+
if callable(typer) and not rate:
|
|
136
|
+
typer(text)
|
|
137
|
+
return
|
|
138
|
+
for char in text:
|
|
139
|
+
self._client.keyPress(_char_key(char))
|
|
140
|
+
if rate:
|
|
141
|
+
time.sleep(rate)
|
|
142
|
+
|
|
143
|
+
def key(self, combo: KeyCombo) -> None:
|
|
144
|
+
parts = [_vnc_name(name) for name in combo.modifiers]
|
|
145
|
+
parts.append(_vnc_name(combo.key))
|
|
146
|
+
# vncdotool spells a combination with dashes: ctrl-shift-t.
|
|
147
|
+
self._client.keyPress("-".join(parts))
|
|
148
|
+
|
|
149
|
+
def close(self) -> None:
|
|
150
|
+
# The connection may already be gone; closing must never mask the real failure.
|
|
151
|
+
with contextlib.suppress(Exception):
|
|
152
|
+
self._client.disconnect()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _open(data: bytes) -> Any:
|
|
156
|
+
import io
|
|
157
|
+
|
|
158
|
+
from PIL import Image
|
|
159
|
+
|
|
160
|
+
return Image.open(io.BytesIO(data))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _vnc_name(name: str) -> str:
|
|
164
|
+
mapped = VNC_KEYS.get(name)
|
|
165
|
+
if mapped is not None:
|
|
166
|
+
return mapped
|
|
167
|
+
if len(name) == 1:
|
|
168
|
+
return _char_key(name)
|
|
169
|
+
raise ActionFailedError(f"the vnc backend has no mapping for key {name!r}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
#: Characters vncdotool spells by X11 keysym name rather than literally.
|
|
173
|
+
_CHAR_KEYS = {
|
|
174
|
+
" ": "space",
|
|
175
|
+
"-": "minus",
|
|
176
|
+
"+": "plus",
|
|
177
|
+
"=": "equal",
|
|
178
|
+
".": "period",
|
|
179
|
+
",": "comma",
|
|
180
|
+
"\t": "tab",
|
|
181
|
+
"\n": "return",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _char_key(char: str) -> str:
|
|
186
|
+
return _CHAR_KEYS.get(char, char)
|