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.
@@ -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)