hai-drivers 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.
- hai_drivers/common/exceptions.py +15 -0
- hai_drivers/common/types.py +205 -0
- hai_drivers/desktop/interface.py +143 -0
- hai_drivers/desktop/local/__init__.py +5 -0
- hai_drivers/desktop/local/driver.py +306 -0
- hai_drivers/desktop/models.py +12 -0
- hai_drivers/desktop/utils.py +45 -0
- hai_drivers/py.typed +0 -0
- hai_drivers/web/exceptions.py +50 -0
- hai_drivers/web/h.js +546 -0
- hai_drivers/web/interface.py +520 -0
- hai_drivers/web/markdown_converter.py +115 -0
- hai_drivers/web/selenium/__init__.py +1 -0
- hai_drivers/web/selenium/driver.py +520 -0
- hai_drivers/web/selenium/py.typed +0 -0
- hai_drivers-0.1.0.dist-info/METADATA +40 -0
- hai_drivers-0.1.0.dist-info/RECORD +19 -0
- hai_drivers-0.1.0.dist-info/WHEEL +4 -0
- hai_drivers-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DriverException(Exception):
|
|
5
|
+
"""Base exception for all driver exceptions."""
|
|
6
|
+
|
|
7
|
+
error_type: str = "driver_error"
|
|
8
|
+
description: str = "A driver error occurred."
|
|
9
|
+
|
|
10
|
+
def to_dict(self) -> dict[str, Any]:
|
|
11
|
+
return {
|
|
12
|
+
"error_type": self.error_type,
|
|
13
|
+
"description": self.description,
|
|
14
|
+
"message": self.args,
|
|
15
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
MouseButton = Literal["left", "right", "middle"]
|
|
4
|
+
|
|
5
|
+
ScrollDirection = Literal["up", "down", "left", "right"]
|
|
6
|
+
|
|
7
|
+
SecretField = Literal["username", "password", "email", "totp", "api_key"]
|
|
8
|
+
|
|
9
|
+
Key = Literal[
|
|
10
|
+
"\t",
|
|
11
|
+
"\n",
|
|
12
|
+
"\r",
|
|
13
|
+
" ",
|
|
14
|
+
"!",
|
|
15
|
+
'"',
|
|
16
|
+
"#",
|
|
17
|
+
"$",
|
|
18
|
+
"%",
|
|
19
|
+
"&",
|
|
20
|
+
"'",
|
|
21
|
+
"(",
|
|
22
|
+
")",
|
|
23
|
+
"*",
|
|
24
|
+
"+",
|
|
25
|
+
",",
|
|
26
|
+
"-",
|
|
27
|
+
".",
|
|
28
|
+
"/",
|
|
29
|
+
"0",
|
|
30
|
+
"1",
|
|
31
|
+
"2",
|
|
32
|
+
"3",
|
|
33
|
+
"4",
|
|
34
|
+
"5",
|
|
35
|
+
"6",
|
|
36
|
+
"7",
|
|
37
|
+
"8",
|
|
38
|
+
"9",
|
|
39
|
+
":",
|
|
40
|
+
";",
|
|
41
|
+
"<",
|
|
42
|
+
"=",
|
|
43
|
+
">",
|
|
44
|
+
"?",
|
|
45
|
+
"@",
|
|
46
|
+
"[",
|
|
47
|
+
"\\",
|
|
48
|
+
"]",
|
|
49
|
+
"^",
|
|
50
|
+
"_",
|
|
51
|
+
"`",
|
|
52
|
+
"a",
|
|
53
|
+
"b",
|
|
54
|
+
"c",
|
|
55
|
+
"d",
|
|
56
|
+
"e",
|
|
57
|
+
"f",
|
|
58
|
+
"g",
|
|
59
|
+
"h",
|
|
60
|
+
"i",
|
|
61
|
+
"j",
|
|
62
|
+
"k",
|
|
63
|
+
"l",
|
|
64
|
+
"m",
|
|
65
|
+
"n",
|
|
66
|
+
"o",
|
|
67
|
+
"p",
|
|
68
|
+
"q",
|
|
69
|
+
"r",
|
|
70
|
+
"s",
|
|
71
|
+
"t",
|
|
72
|
+
"u",
|
|
73
|
+
"v",
|
|
74
|
+
"w",
|
|
75
|
+
"x",
|
|
76
|
+
"y",
|
|
77
|
+
"z",
|
|
78
|
+
"{",
|
|
79
|
+
"|",
|
|
80
|
+
"}",
|
|
81
|
+
"~",
|
|
82
|
+
"accept",
|
|
83
|
+
"add",
|
|
84
|
+
"alt",
|
|
85
|
+
"altleft",
|
|
86
|
+
"altright",
|
|
87
|
+
"apps",
|
|
88
|
+
"backspace",
|
|
89
|
+
"browserback",
|
|
90
|
+
"browserfavorites",
|
|
91
|
+
"browserforward",
|
|
92
|
+
"browserhome",
|
|
93
|
+
"browserrefresh",
|
|
94
|
+
"browsersearch",
|
|
95
|
+
"browserstop",
|
|
96
|
+
"capslock",
|
|
97
|
+
"clear",
|
|
98
|
+
"convert",
|
|
99
|
+
"ctrl",
|
|
100
|
+
"ctrlleft",
|
|
101
|
+
"ctrlright",
|
|
102
|
+
"decimal",
|
|
103
|
+
"del",
|
|
104
|
+
"delete",
|
|
105
|
+
"divide",
|
|
106
|
+
"down",
|
|
107
|
+
"end",
|
|
108
|
+
"enter",
|
|
109
|
+
"esc",
|
|
110
|
+
"escape",
|
|
111
|
+
"execute",
|
|
112
|
+
"f1",
|
|
113
|
+
"f10",
|
|
114
|
+
"f11",
|
|
115
|
+
"f12",
|
|
116
|
+
"f13",
|
|
117
|
+
"f14",
|
|
118
|
+
"f15",
|
|
119
|
+
"f16",
|
|
120
|
+
"f17",
|
|
121
|
+
"f18",
|
|
122
|
+
"f19",
|
|
123
|
+
"f2",
|
|
124
|
+
"f20",
|
|
125
|
+
"f21",
|
|
126
|
+
"f22",
|
|
127
|
+
"f23",
|
|
128
|
+
"f24",
|
|
129
|
+
"f3",
|
|
130
|
+
"f4",
|
|
131
|
+
"f5",
|
|
132
|
+
"f6",
|
|
133
|
+
"f7",
|
|
134
|
+
"f8",
|
|
135
|
+
"f9",
|
|
136
|
+
"final",
|
|
137
|
+
"fn",
|
|
138
|
+
"hanguel",
|
|
139
|
+
"hangul",
|
|
140
|
+
"hanja",
|
|
141
|
+
"help",
|
|
142
|
+
"home",
|
|
143
|
+
"insert",
|
|
144
|
+
"junja",
|
|
145
|
+
"kana",
|
|
146
|
+
"kanji",
|
|
147
|
+
"launchapp1",
|
|
148
|
+
"launchapp2",
|
|
149
|
+
"launchmail",
|
|
150
|
+
"launchmediaselect",
|
|
151
|
+
"left",
|
|
152
|
+
"modechange",
|
|
153
|
+
"multiply",
|
|
154
|
+
"nexttrack",
|
|
155
|
+
"nonconvert",
|
|
156
|
+
"num0",
|
|
157
|
+
"num1",
|
|
158
|
+
"num2",
|
|
159
|
+
"num3",
|
|
160
|
+
"num4",
|
|
161
|
+
"num5",
|
|
162
|
+
"num6",
|
|
163
|
+
"num7",
|
|
164
|
+
"num8",
|
|
165
|
+
"num9",
|
|
166
|
+
"numlock",
|
|
167
|
+
"pagedown",
|
|
168
|
+
"pageup",
|
|
169
|
+
"pause",
|
|
170
|
+
"pgdn",
|
|
171
|
+
"pgup",
|
|
172
|
+
"playpause",
|
|
173
|
+
"prevtrack",
|
|
174
|
+
"print",
|
|
175
|
+
"printscreen",
|
|
176
|
+
"prntscrn",
|
|
177
|
+
"prtsc",
|
|
178
|
+
"prtscr",
|
|
179
|
+
"return",
|
|
180
|
+
"right",
|
|
181
|
+
"scrolllock",
|
|
182
|
+
"select",
|
|
183
|
+
"separator",
|
|
184
|
+
"shift",
|
|
185
|
+
"shiftleft",
|
|
186
|
+
"shiftright",
|
|
187
|
+
"sleep",
|
|
188
|
+
"space",
|
|
189
|
+
"stop",
|
|
190
|
+
"subtract",
|
|
191
|
+
"tab",
|
|
192
|
+
"up",
|
|
193
|
+
"volumedown",
|
|
194
|
+
"volumemute",
|
|
195
|
+
"volumeup",
|
|
196
|
+
"win",
|
|
197
|
+
"winleft",
|
|
198
|
+
"winright",
|
|
199
|
+
"yen",
|
|
200
|
+
"command",
|
|
201
|
+
"option",
|
|
202
|
+
"optionleft",
|
|
203
|
+
"optionright",
|
|
204
|
+
"universal_command",
|
|
205
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from hai_drivers.common.types import Key, MouseButton, ScrollDirection
|
|
7
|
+
from hai_drivers.desktop.models import RunCommandResponse
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DesktopDriverInterface(ABC):
|
|
12
|
+
@cached_property
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def platform(self) -> str:
|
|
15
|
+
"""Get the platform of the desktop."""
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def screenshot_b64(self) -> str:
|
|
19
|
+
"""Take a screenshot and return it as a base64 string."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def get_screen_size(self) -> tuple[int, int]:
|
|
23
|
+
"""Get the screen dimensions as (width, height)."""
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def get_mouse_position(self) -> tuple[int, int]:
|
|
27
|
+
"""Get the current mouse position as (x, y)."""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def get_accessibility_tree(self) -> str:
|
|
31
|
+
"""Get the accessibility tree of the visible screen as an xml string."""
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def get_number_of_pixels_scrolled_per_click(self) -> int:
|
|
35
|
+
"""Return the number of px scrolled by a scrolling click.
|
|
36
|
+
|
|
37
|
+
This value should either be a constant or cached by the implementation.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
def mouse_move_to(self, x: int, y: int):
|
|
42
|
+
"""Move the mouse to the coordinates."""
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def mouse_press(self, button: MouseButton):
|
|
46
|
+
"""Press a mouse button down without releasing it."""
|
|
47
|
+
|
|
48
|
+
@abstractmethod
|
|
49
|
+
def mouse_release(self, button: MouseButton):
|
|
50
|
+
"""Release a previously pressed mouse button."""
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def click(self, x: int, y: int, button: MouseButton = "left"):
|
|
54
|
+
"""Click at the specified screen coordinates."""
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
def double_click(self, x: int, y: int, button: MouseButton = "left", delay_between_clicks: float = 0.05):
|
|
58
|
+
"""Double click at the specified screen coordinates."""
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def write(self, text: str, delay_between_keys: float = 0.05) -> None:
|
|
62
|
+
"""Type a string of text.
|
|
63
|
+
Capital letters and other special characters will be translated into the correct keycode sequence.
|
|
64
|
+
|
|
65
|
+
delay_between_keys: secs between each key press.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def hotkey(self, keys: list[Key]):
|
|
70
|
+
"""Performs key down presses on the arguments passed in order, then performs key releases in reverse order."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def tap_key(self, key: Key):
|
|
74
|
+
"""Tap a keyboard key. Performs both press and release of the key."""
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def press_key(self, key: Key):
|
|
78
|
+
"""Press a keyboard key down without releasing it.
|
|
79
|
+
|
|
80
|
+
Behaviour of pressing a Key might be OS specific. e.g Ctrl vs Command
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def release_key(self, key: Key):
|
|
85
|
+
"""Release a previously pressed keyboard key."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def scroll_by_n_clicks(self, direction: ScrollDirection, clicks: int):
|
|
89
|
+
"""Scroll in a direction by a number of atomic scrolling click.
|
|
90
|
+
|
|
91
|
+
The size of a click differs per platform, you can get it with get_number_of_pixels_scrolled_per_click().
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def scroll_by_n_pxs(self, direction: ScrollDirection, pixels: int):
|
|
95
|
+
"""Scroll in a direction by at least `pixels`, 1 px ~= 0.26 mm.
|
|
96
|
+
|
|
97
|
+
If pixels is not a multiple of get_number_of_pixels_scrolled_per_click(), the n pixels scrolled will be higher than pixels.
|
|
98
|
+
"""
|
|
99
|
+
npixels_per_click = self.get_number_of_pixels_scrolled_per_click()
|
|
100
|
+
self.scroll_by_n_clicks(
|
|
101
|
+
direction=direction,
|
|
102
|
+
clicks=(pixels // npixels_per_click) + (1 if pixels % npixels_per_click > 0 else 0),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def run_command(
|
|
107
|
+
self,
|
|
108
|
+
command: Sequence[str],
|
|
109
|
+
timeout: int | None = 60,
|
|
110
|
+
env: dict[str, str] | None = None,
|
|
111
|
+
cwd: Path | None = None,
|
|
112
|
+
detach: bool = False,
|
|
113
|
+
ignore_errors: bool = False,
|
|
114
|
+
) -> RunCommandResponse:
|
|
115
|
+
"""Run a shell command on the desktop."""
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
def read_file(self, path: str) -> bytes:
|
|
119
|
+
"""Read a file from the desktop filesystem. Returns raw bytes."""
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def write_file(self, path: str, content: bytes) -> None:
|
|
123
|
+
"""Write raw bytes to a file on the desktop filesystem."""
|
|
124
|
+
|
|
125
|
+
def get_observation_snapshot(self) -> "ObservationSnapshot":
|
|
126
|
+
"""Bundle the data needed to build an observation in one round-trip.
|
|
127
|
+
|
|
128
|
+
Drivers with cheap AX trees or window metadata override this so
|
|
129
|
+
observations don't pay for multiple sequential round-trips.
|
|
130
|
+
"""
|
|
131
|
+
return ObservationSnapshot(
|
|
132
|
+
screenshot_b64=self.screenshot_b64(),
|
|
133
|
+
cursor_position=self.get_mouse_position(),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ObservationSnapshot(BaseModel):
|
|
138
|
+
model_config = ConfigDict(frozen=True)
|
|
139
|
+
|
|
140
|
+
screenshot_b64: str
|
|
141
|
+
cursor_position: tuple[int, int] | None = None
|
|
142
|
+
accessibility_tree: str | None = None
|
|
143
|
+
metadata: dict[str, str] = Field(default_factory=dict)
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Local desktop driver using pyautogui and pynput."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
import logging
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
import pyautogui
|
|
12
|
+
from hai_drivers.common.types import Key, MouseButton, ScrollDirection
|
|
13
|
+
from hai_drivers.desktop.interface import DesktopDriverInterface, ObservationSnapshot
|
|
14
|
+
from hai_drivers.desktop.models import RunCommandResponse
|
|
15
|
+
from hai_drivers.desktop.utils import run_command_impl
|
|
16
|
+
from PIL import Image as PILImage
|
|
17
|
+
from pynput.keyboard import Controller
|
|
18
|
+
from pynput.keyboard import Key as PynputKey
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
MACOS_CLICK_TO_KEYBOARD_SETTLE_S = 0.2
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LocalDesktopDriver(DesktopDriverInterface):
|
|
25
|
+
"""Local desktop driver with pyautogui and pynput."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, screenshot_max_width: int | None = None):
|
|
28
|
+
"""Create a pyautogui desktop driver.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
screenshot_max_width: Downscale model-facing screenshots wider than
|
|
32
|
+
this before upload (pyautogui captures native HiDPI). ``None``
|
|
33
|
+
keeps native resolution.
|
|
34
|
+
"""
|
|
35
|
+
if screenshot_max_width is not None and screenshot_max_width <= 0:
|
|
36
|
+
raise ValueError(f"screenshot_max_width must be a positive int or None; got {screenshot_max_width}")
|
|
37
|
+
|
|
38
|
+
pyautogui.FAILSAFE = False
|
|
39
|
+
pyautogui.PAUSE = 0
|
|
40
|
+
_patch_pyautogui_macos_multiclick()
|
|
41
|
+
self._keyboard = Controller()
|
|
42
|
+
self._screenshot_max_width: int | None = screenshot_max_width
|
|
43
|
+
# pyautogui itself assumes a fixed primary display for the process
|
|
44
|
+
# lifetime, so cache the logical screen size under the same assumption.
|
|
45
|
+
size = pyautogui.size()
|
|
46
|
+
self._screen_size: tuple[int, int] = (size.width, size.height)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def platform(self) -> str:
|
|
50
|
+
"""Get the platform of the desktop."""
|
|
51
|
+
return platform.system()
|
|
52
|
+
|
|
53
|
+
def screenshot_b64(self) -> str:
|
|
54
|
+
"""Take a native-resolution screenshot and return it as a base64-encoded PNG string."""
|
|
55
|
+
return base64.b64encode(_encode_png(pyautogui.screenshot())).decode("ascii")
|
|
56
|
+
|
|
57
|
+
def get_observation_snapshot(self) -> ObservationSnapshot:
|
|
58
|
+
"""Capture one model-facing screenshot and cursor position.
|
|
59
|
+
|
|
60
|
+
pyautogui can report cursor/screen coordinates in logical pixels while
|
|
61
|
+
screenshots arrive in physical HiDPI pixels. Cursor coordinates are
|
|
62
|
+
first mapped into the screenshot image frame, then scaled again if the
|
|
63
|
+
image is downsampled. The capture stays a live PIL image until the
|
|
64
|
+
single encode at output size.
|
|
65
|
+
"""
|
|
66
|
+
captured = pyautogui.screenshot()
|
|
67
|
+
cursor = self._cursor_position_in_screenshot(
|
|
68
|
+
cursor_xy=self.get_mouse_position(),
|
|
69
|
+
screenshot_size=(captured.width, captured.height),
|
|
70
|
+
)
|
|
71
|
+
if self._screenshot_max_width is not None and captured.width > self._screenshot_max_width:
|
|
72
|
+
image, out_cursor = _resize_image_to_width(captured, self._screenshot_max_width, cursor)
|
|
73
|
+
else:
|
|
74
|
+
image, out_cursor = captured, cursor
|
|
75
|
+
return ObservationSnapshot(
|
|
76
|
+
screenshot_b64=base64.b64encode(_encode_png(image)).decode("ascii"),
|
|
77
|
+
cursor_position=out_cursor,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _cursor_position_in_screenshot(
|
|
81
|
+
self, *, cursor_xy: tuple[int, int], screenshot_size: tuple[int, int]
|
|
82
|
+
) -> tuple[int, int]:
|
|
83
|
+
"""Map pyautogui cursor coordinates into the screenshot pixel frame."""
|
|
84
|
+
screen_w, screen_h = self.get_screen_size()
|
|
85
|
+
image_w, image_h = screenshot_size
|
|
86
|
+
scale_x = image_w / screen_w
|
|
87
|
+
scale_y = image_h / screen_h
|
|
88
|
+
return (round(cursor_xy[0] * scale_x), round(cursor_xy[1] * scale_y))
|
|
89
|
+
|
|
90
|
+
def get_screen_size(self) -> tuple[int, int]:
|
|
91
|
+
"""Get the screen dimensions as (width, height)."""
|
|
92
|
+
return self._screen_size
|
|
93
|
+
|
|
94
|
+
def get_mouse_position(self) -> tuple[int, int]:
|
|
95
|
+
"""Get the current mouse position as (x, y)."""
|
|
96
|
+
pos = pyautogui.position()
|
|
97
|
+
return (pos.x, pos.y)
|
|
98
|
+
|
|
99
|
+
def get_accessibility_tree(self) -> str:
|
|
100
|
+
"""Get the accessibility tree of the visible screen as an xml string.
|
|
101
|
+
|
|
102
|
+
Note: This requires platform-specific implementations.
|
|
103
|
+
Not currently supported by pyautogui/pynput.
|
|
104
|
+
"""
|
|
105
|
+
raise NotImplementedError(
|
|
106
|
+
"Accessibility tree not available with pyautogui/pynput. "
|
|
107
|
+
"Use platform-specific drivers for accessibility tree access."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def get_number_of_pixels_scrolled_per_click(self) -> int:
|
|
111
|
+
"""Return the number of px scrolled by a scrolling click.
|
|
112
|
+
|
|
113
|
+
- Windows: 120 (WHEEL_DELTA: https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-mousewheel)
|
|
114
|
+
- Linux: 53 (empirical GTK/X11 default, varies by DE)
|
|
115
|
+
- macOS: 20 (empirical, varies due to smooth scrolling)
|
|
116
|
+
"""
|
|
117
|
+
return {"Darwin": 20, "Windows": 120}.get(platform.system(), 53)
|
|
118
|
+
|
|
119
|
+
def mouse_move_to(self, x: int, y: int):
|
|
120
|
+
"""Move the mouse to the coordinates."""
|
|
121
|
+
pyautogui.moveTo(x, y)
|
|
122
|
+
|
|
123
|
+
def mouse_press(self, button: MouseButton):
|
|
124
|
+
"""Press a mouse button down without releasing it."""
|
|
125
|
+
pyautogui.mouseDown(button=button)
|
|
126
|
+
|
|
127
|
+
def mouse_release(self, button: MouseButton):
|
|
128
|
+
"""Release a previously pressed mouse button."""
|
|
129
|
+
pyautogui.mouseUp(button=button)
|
|
130
|
+
|
|
131
|
+
def click(self, x: int, y: int, button: MouseButton = "left"):
|
|
132
|
+
"""Click at the specified screen coordinates."""
|
|
133
|
+
pyautogui.click(x=x, y=y, button=button)
|
|
134
|
+
_settle_after_click_for_focus()
|
|
135
|
+
|
|
136
|
+
def double_click(self, x: int, y: int, button: MouseButton = "left", delay_between_clicks: float = 0.05):
|
|
137
|
+
"""Double click at the specified screen coordinates."""
|
|
138
|
+
pyautogui.doubleClick(x=x, y=y, button=button, interval=delay_between_clicks)
|
|
139
|
+
_settle_after_click_for_focus()
|
|
140
|
+
|
|
141
|
+
def write(self, text: str, delay_between_keys: float = 0.05):
|
|
142
|
+
"""Type a string of text.
|
|
143
|
+
|
|
144
|
+
Capital letters and other special characters will be translated into the correct keycode sequence.
|
|
145
|
+
delay_between_keys: secs between each key press.
|
|
146
|
+
"""
|
|
147
|
+
if delay_between_keys > 0:
|
|
148
|
+
for char in text:
|
|
149
|
+
self._keyboard.type(char)
|
|
150
|
+
time.sleep(delay_between_keys)
|
|
151
|
+
else:
|
|
152
|
+
self._keyboard.type(text)
|
|
153
|
+
|
|
154
|
+
def press_key(self, key: Key):
|
|
155
|
+
"""Press a keyboard key down without releasing it.
|
|
156
|
+
|
|
157
|
+
Behaviour of pressing a Key might be OS specific. e.g Ctrl vs Command
|
|
158
|
+
"""
|
|
159
|
+
key_parsed = self._parse_pynput_key(key)
|
|
160
|
+
self._keyboard.press(key_parsed)
|
|
161
|
+
|
|
162
|
+
def release_key(self, key: Key):
|
|
163
|
+
"""Release a previously pressed keyboard key."""
|
|
164
|
+
key_parsed = self._parse_pynput_key(key)
|
|
165
|
+
self._keyboard.release(key_parsed)
|
|
166
|
+
|
|
167
|
+
def tap_key(self, key: Key):
|
|
168
|
+
"""Tap a keyboard key. Performs both press and release of the key."""
|
|
169
|
+
self.press_key(key)
|
|
170
|
+
self.release_key(key)
|
|
171
|
+
|
|
172
|
+
def hotkey(self, keys: list[Key]):
|
|
173
|
+
"""Performs key down presses on the arguments passed in order, then performs key releases in reverse order."""
|
|
174
|
+
for key in keys:
|
|
175
|
+
self.press_key(key)
|
|
176
|
+
for key in reversed(keys):
|
|
177
|
+
self.release_key(key)
|
|
178
|
+
|
|
179
|
+
def scroll_by_n_clicks(self, direction: ScrollDirection, clicks: int):
|
|
180
|
+
"""Scroll in a direction by a number of atomic scrolling click.
|
|
181
|
+
|
|
182
|
+
The size of a click differs per platform, you can get it with get_number_of_pixels_scrolled_per_click().
|
|
183
|
+
"""
|
|
184
|
+
if direction == "up":
|
|
185
|
+
pyautogui.scroll(clicks)
|
|
186
|
+
elif direction == "down":
|
|
187
|
+
pyautogui.scroll(-clicks)
|
|
188
|
+
elif direction == "left":
|
|
189
|
+
pyautogui.hscroll(-clicks)
|
|
190
|
+
elif direction == "right":
|
|
191
|
+
pyautogui.hscroll(clicks)
|
|
192
|
+
else:
|
|
193
|
+
raise ValueError(f"Invalid scroll direction: {direction}")
|
|
194
|
+
|
|
195
|
+
def _parse_pynput_key(self, key: str) -> PynputKey | str:
|
|
196
|
+
"""Parse a key string to a pynput.keyboard.Key enum or a string."""
|
|
197
|
+
if key in PynputKey.__members__:
|
|
198
|
+
return PynputKey[key]
|
|
199
|
+
if key == "command":
|
|
200
|
+
return PynputKey.cmd
|
|
201
|
+
return key
|
|
202
|
+
|
|
203
|
+
def run_command(
|
|
204
|
+
self,
|
|
205
|
+
command: Sequence[str],
|
|
206
|
+
timeout: int | None = 60,
|
|
207
|
+
env: dict[str, str] | None = None,
|
|
208
|
+
cwd: Path | None = None,
|
|
209
|
+
detach: bool = False,
|
|
210
|
+
ignore_errors: bool = False,
|
|
211
|
+
) -> RunCommandResponse:
|
|
212
|
+
"""Run a shell command on the local desktop using subprocess."""
|
|
213
|
+
return run_command_impl(
|
|
214
|
+
command=command,
|
|
215
|
+
timeout=timeout,
|
|
216
|
+
env=env,
|
|
217
|
+
cwd=cwd,
|
|
218
|
+
detach=detach,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def read_file(self, path: str) -> bytes:
|
|
222
|
+
"""Read a file from the local filesystem."""
|
|
223
|
+
p = Path(path).expanduser()
|
|
224
|
+
if not p.exists():
|
|
225
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
226
|
+
return p.read_bytes()
|
|
227
|
+
|
|
228
|
+
def write_file(self, path: str, content: bytes) -> None:
|
|
229
|
+
"""Write a file to the local filesystem."""
|
|
230
|
+
p = Path(path).expanduser()
|
|
231
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
p.write_bytes(content)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _resize_image_to_width(
|
|
236
|
+
image: PILImage.Image, width: int, cursor_xy: tuple[int, int] | None
|
|
237
|
+
) -> tuple[PILImage.Image, tuple[int, int] | None]:
|
|
238
|
+
"""Resize the image to ``width`` and scale the cursor to match.
|
|
239
|
+
|
|
240
|
+
Operates on the live PIL image so callers encode exactly once, at output size.
|
|
241
|
+
"""
|
|
242
|
+
scale = width / image.width
|
|
243
|
+
new_size = (width, max(1, round(image.height * scale)))
|
|
244
|
+
resized = image.resize(new_size, PILImage.Resampling.LANCZOS)
|
|
245
|
+
out_cursor = (round(cursor_xy[0] * scale), round(cursor_xy[1] * scale)) if cursor_xy is not None else None
|
|
246
|
+
return resized, out_cursor
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _encode_png(image: PILImage.Image) -> bytes:
|
|
250
|
+
buffer = io.BytesIO()
|
|
251
|
+
image.save(buffer, format="PNG")
|
|
252
|
+
return buffer.getvalue()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _settle_after_click_for_focus() -> None:
|
|
256
|
+
if platform.system() == "Darwin":
|
|
257
|
+
time.sleep(MACOS_CLICK_TO_KEYBOARD_SETTLE_S)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _patch_pyautogui_macos_multiclick() -> None:
|
|
261
|
+
"""Apply PyAutoGUI PR #949's macOS click-state fix."""
|
|
262
|
+
if platform.system() != "Darwin":
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
platform_module = getattr(pyautogui, "platformModule", None)
|
|
266
|
+
if platform_module is None:
|
|
267
|
+
logger.info("Skipping macOS PyAutoGUI multi-click patch: platformModule is unavailable")
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
if getattr(platform_module, "_hai_drivers_macos_multiclick_patch", False):
|
|
271
|
+
logger.info("Skipping macOS PyAutoGUI multi-click patch: patch is already applied")
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
quartz = getattr(platform_module, "Quartz", None)
|
|
275
|
+
if quartz is None:
|
|
276
|
+
logger.info("Skipping macOS PyAutoGUI multi-click patch: Quartz is unavailable")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
def _button_events(button: MouseButton):
|
|
280
|
+
if button == platform_module.LEFT:
|
|
281
|
+
return quartz.kCGMouseButtonLeft, quartz.kCGEventLeftMouseDown, quartz.kCGEventLeftMouseUp
|
|
282
|
+
if button == platform_module.MIDDLE:
|
|
283
|
+
return quartz.kCGMouseButtonCenter, quartz.kCGEventOtherMouseDown, quartz.kCGEventOtherMouseUp
|
|
284
|
+
if button == platform_module.RIGHT:
|
|
285
|
+
return quartz.kCGMouseButtonRight, quartz.kCGEventRightMouseDown, quartz.kCGEventRightMouseUp
|
|
286
|
+
raise AssertionError("button argument not in ('left', 'middle', 'right')")
|
|
287
|
+
|
|
288
|
+
def _send_mouse_event(event_type, x: int, y: int, button, click_state: int | None = None) -> None:
|
|
289
|
+
mouse_event = quartz.CGEventCreateMouseEvent(None, event_type, (x, y), button)
|
|
290
|
+
if click_state is not None:
|
|
291
|
+
quartz.CGEventSetIntegerValueField(mouse_event, quartz.kCGMouseEventClickState, click_state)
|
|
292
|
+
quartz.CGEventPost(quartz.kCGHIDEventTap, mouse_event)
|
|
293
|
+
|
|
294
|
+
def _multi_click(x: int, y: int, button: MouseButton, num: int, interval: float = 0.0) -> None:
|
|
295
|
+
native_button, down, up = _button_events(button)
|
|
296
|
+
for index in range(num):
|
|
297
|
+
pyautogui.failSafeCheck()
|
|
298
|
+
click_state = index + 1
|
|
299
|
+
platform_module._sendMouseEvent(down, x, y, native_button, click_state)
|
|
300
|
+
platform_module._sendMouseEvent(up, x, y, native_button, click_state)
|
|
301
|
+
time.sleep(interval)
|
|
302
|
+
|
|
303
|
+
platform_module._sendMouseEvent = _send_mouse_event
|
|
304
|
+
platform_module._multiClick = _multi_click
|
|
305
|
+
platform_module._hai_drivers_macos_multiclick_patch = True
|
|
306
|
+
logger.info("Applied macOS PyAutoGUI multi-click patch")
|