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.
@@ -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,5 @@
1
+ """Local desktop driver."""
2
+
3
+ from hai_drivers.desktop.local.driver import LocalDesktopDriver
4
+
5
+ __all__ = ["LocalDesktopDriver"]
@@ -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")
@@ -0,0 +1,12 @@
1
+ """Models shared across desktop drivers."""
2
+
3
+ from pydantic import BaseModel
4
+
5
+
6
+ class RunCommandResponse(BaseModel):
7
+ """Response from running a shell command."""
8
+
9
+ returncode: int
10
+ stdout: str
11
+ stderr: str
12
+ exception: str | None = None