codeaway 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.
- codeaway/__init__.py +4 -0
- codeaway/__main__.py +5 -0
- codeaway/agents.py +427 -0
- codeaway/cli.py +221 -0
- codeaway/config.py +157 -0
- codeaway/desktop.py +644 -0
- codeaway/server.py +673 -0
- codeaway/web/__init__.py +1 -0
- codeaway/web/app.js +747 -0
- codeaway/web/index.html +41 -0
- codeaway/web/setup.html +57 -0
- codeaway/web/style.css +280 -0
- codeaway-0.1.0.dist-info/METADATA +113 -0
- codeaway-0.1.0.dist-info/RECORD +17 -0
- codeaway-0.1.0.dist-info/WHEEL +4 -0
- codeaway-0.1.0.dist-info/entry_points.txt +2 -0
- codeaway-0.1.0.dist-info/licenses/LICENSE +21 -0
codeaway/desktop.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import Enum
|
|
3
|
+
import math
|
|
4
|
+
from numbers import Real
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from PIL import Image, ImageGrab
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _pin_thread_v2_dpi(user32: Any | None = None) -> bool:
|
|
12
|
+
"""Make Win32 rectangles and screen captures use physical pixels."""
|
|
13
|
+
import ctypes
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
if user32 is None:
|
|
17
|
+
if not sys.platform.startswith("win"):
|
|
18
|
+
return False
|
|
19
|
+
user32 = ctypes.windll.user32
|
|
20
|
+
try:
|
|
21
|
+
setter = user32.SetThreadDpiAwarenessContext
|
|
22
|
+
setter.argtypes = (ctypes.c_void_p,)
|
|
23
|
+
setter.restype = ctypes.c_void_p
|
|
24
|
+
return bool(setter(ctypes.c_void_p(-4))) # PER_MONITOR_AWARE_V2
|
|
25
|
+
except Exception:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class PixelPoint:
|
|
31
|
+
x: int
|
|
32
|
+
y: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class PixelRegion:
|
|
37
|
+
x: int
|
|
38
|
+
y: int
|
|
39
|
+
width: int
|
|
40
|
+
height: int
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def center(self) -> PixelPoint:
|
|
44
|
+
return PixelPoint(self.x + self.width // 2, self.y + self.height // 2)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class FractionalRegion:
|
|
49
|
+
x: float
|
|
50
|
+
y: float
|
|
51
|
+
width: float
|
|
52
|
+
height: float
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
values = (self.x, self.y, self.width, self.height)
|
|
56
|
+
if any(isinstance(value, bool) or not isinstance(value, Real) for value in values):
|
|
57
|
+
raise TypeError("fractional region values must be numbers")
|
|
58
|
+
if any(not math.isfinite(value) for value in values):
|
|
59
|
+
raise ValueError("fractional region values must be finite")
|
|
60
|
+
if not 0 <= self.x <= 1 or not 0 <= self.y <= 1:
|
|
61
|
+
raise ValueError("fractional region origin must be between 0 and 1")
|
|
62
|
+
if self.width <= 0 or self.height <= 0:
|
|
63
|
+
raise ValueError("fractional region dimensions must be positive")
|
|
64
|
+
if self.width > 1 or self.height > 1:
|
|
65
|
+
raise ValueError("fractional region dimensions must be at most 1")
|
|
66
|
+
if self.x + self.width > 1 or self.y + self.height > 1:
|
|
67
|
+
raise ValueError("fractional region must fit inside its parent")
|
|
68
|
+
|
|
69
|
+
def resolve(self, parent: PixelRegion) -> PixelRegion:
|
|
70
|
+
return PixelRegion(
|
|
71
|
+
parent.x + round(parent.width * self.x),
|
|
72
|
+
parent.y + round(parent.height * self.y),
|
|
73
|
+
round(parent.width * self.width),
|
|
74
|
+
round(parent.height * self.height),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class DesktopWindow:
|
|
80
|
+
id: str
|
|
81
|
+
native_handle: int
|
|
82
|
+
title: str
|
|
83
|
+
process_path: str
|
|
84
|
+
region: PixelRegion
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AccessibilityAction(str, Enum):
|
|
88
|
+
INVOKE = "invoke"
|
|
89
|
+
EXPAND = "expand"
|
|
90
|
+
COLLAPSE = "collapse"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True)
|
|
94
|
+
class AccessibilityNode:
|
|
95
|
+
id: str
|
|
96
|
+
role: str
|
|
97
|
+
name: str
|
|
98
|
+
class_name: str
|
|
99
|
+
region: PixelRegion
|
|
100
|
+
depth: int = 0
|
|
101
|
+
expanded: bool | None = None
|
|
102
|
+
actions: frozenset[AccessibilityAction] = frozenset()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class InputUnavailable(RuntimeError):
|
|
106
|
+
"""Global input was aborted because its exact target was not safe."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AccessibilityUnavailable(RuntimeError):
|
|
110
|
+
"""The desktop accessibility provider could not produce a control tree."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class DesktopBackend(Protocol):
|
|
114
|
+
id: str
|
|
115
|
+
|
|
116
|
+
def list_windows(self) -> list[DesktopWindow]: ...
|
|
117
|
+
|
|
118
|
+
def activate(self, window: DesktopWindow) -> bool: ...
|
|
119
|
+
|
|
120
|
+
def is_foreground(self, window: DesktopWindow) -> bool: ...
|
|
121
|
+
|
|
122
|
+
def capture(self, region: PixelRegion) -> Image.Image: ...
|
|
123
|
+
|
|
124
|
+
def accessibility_tree(self, window: DesktopWindow) -> list[AccessibilityNode]: ...
|
|
125
|
+
|
|
126
|
+
def accessibility_action(
|
|
127
|
+
self, node: AccessibilityNode, action: AccessibilityAction
|
|
128
|
+
) -> None: ...
|
|
129
|
+
|
|
130
|
+
def click(self, window: DesktopWindow, point: PixelPoint) -> None: ...
|
|
131
|
+
|
|
132
|
+
def scroll(
|
|
133
|
+
self, window: DesktopWindow, point: PixelPoint, amount: int
|
|
134
|
+
) -> None: ...
|
|
135
|
+
|
|
136
|
+
def paste_and_submit(
|
|
137
|
+
self, window: DesktopWindow, point: PixelPoint, text: str
|
|
138
|
+
) -> None: ...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class _NativeWindow:
|
|
143
|
+
native_handle: int
|
|
144
|
+
title: str
|
|
145
|
+
process_path: str
|
|
146
|
+
region: PixelRegion
|
|
147
|
+
visible: bool
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class _NativeControl:
|
|
152
|
+
id: str
|
|
153
|
+
role: str
|
|
154
|
+
name: str
|
|
155
|
+
class_name: str
|
|
156
|
+
region: PixelRegion
|
|
157
|
+
depth: int
|
|
158
|
+
expanded: bool | None
|
|
159
|
+
actions: frozenset[AccessibilityAction]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class WindowsDesktop:
|
|
163
|
+
"""Windows desktop mechanics behind an injectable native boundary."""
|
|
164
|
+
|
|
165
|
+
id = "windows"
|
|
166
|
+
|
|
167
|
+
def __init__(self, native: "_WindowsNative | None" = None) -> None:
|
|
168
|
+
self._native = native or _WindowsNative()
|
|
169
|
+
self._tree_version = 0
|
|
170
|
+
self._node_controls: dict[str, str] = {}
|
|
171
|
+
|
|
172
|
+
def list_windows(self) -> list[DesktopWindow]:
|
|
173
|
+
_pin_thread_v2_dpi()
|
|
174
|
+
return [
|
|
175
|
+
DesktopWindow(
|
|
176
|
+
id=f"window-{uuid4().hex}",
|
|
177
|
+
native_handle=window.native_handle,
|
|
178
|
+
title=window.title,
|
|
179
|
+
process_path=window.process_path,
|
|
180
|
+
region=window.region,
|
|
181
|
+
)
|
|
182
|
+
for window in self._native.list_windows()
|
|
183
|
+
if window.visible and window.region.width > 0 and window.region.height > 0
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
def activate(self, window: DesktopWindow) -> bool:
|
|
187
|
+
if not _pin_thread_v2_dpi():
|
|
188
|
+
return False
|
|
189
|
+
return self._native.activate(window.native_handle)
|
|
190
|
+
|
|
191
|
+
def is_foreground(self, window: DesktopWindow) -> bool:
|
|
192
|
+
return self._native.is_foreground(window.native_handle)
|
|
193
|
+
|
|
194
|
+
def capture(self, region: PixelRegion) -> Image.Image:
|
|
195
|
+
_pin_thread_v2_dpi()
|
|
196
|
+
return self._native.capture(
|
|
197
|
+
(region.x, region.y, region.x + region.width, region.y + region.height)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def accessibility_tree(self, window: DesktopWindow) -> list[AccessibilityNode]:
|
|
201
|
+
try:
|
|
202
|
+
controls = self._native.accessibility_tree(window.native_handle)
|
|
203
|
+
self._tree_version += 1
|
|
204
|
+
self._node_controls = {}
|
|
205
|
+
nodes: list[AccessibilityNode] = []
|
|
206
|
+
for index, control in enumerate(controls):
|
|
207
|
+
node_id = f"tree-{self._tree_version}-{index}"
|
|
208
|
+
self._node_controls[node_id] = control.id
|
|
209
|
+
nodes.append(
|
|
210
|
+
AccessibilityNode(
|
|
211
|
+
id=node_id,
|
|
212
|
+
role=control.role,
|
|
213
|
+
name=control.name,
|
|
214
|
+
class_name=control.class_name,
|
|
215
|
+
region=control.region,
|
|
216
|
+
depth=control.depth,
|
|
217
|
+
expanded=control.expanded,
|
|
218
|
+
actions=control.actions,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
return nodes
|
|
222
|
+
except AccessibilityUnavailable:
|
|
223
|
+
raise
|
|
224
|
+
except Exception as error:
|
|
225
|
+
raise AccessibilityUnavailable(
|
|
226
|
+
"desktop accessibility is unavailable"
|
|
227
|
+
) from error
|
|
228
|
+
|
|
229
|
+
def accessibility_action(
|
|
230
|
+
self, node: AccessibilityNode, action: AccessibilityAction
|
|
231
|
+
) -> None:
|
|
232
|
+
if action not in node.actions:
|
|
233
|
+
raise ValueError(f"accessibility action {action.value!r} is unavailable")
|
|
234
|
+
control_id = self._node_controls.get(node.id)
|
|
235
|
+
if control_id is None:
|
|
236
|
+
raise ValueError("accessibility node is not from the latest tree read")
|
|
237
|
+
self._native.accessibility_action(control_id, action)
|
|
238
|
+
|
|
239
|
+
def _require_foreground(self, window: DesktopWindow) -> None:
|
|
240
|
+
if not self._native.is_foreground(window.native_handle):
|
|
241
|
+
raise InputUnavailable("the exact target window is not foreground")
|
|
242
|
+
|
|
243
|
+
def click(self, window: DesktopWindow, point: PixelPoint) -> None:
|
|
244
|
+
if not _pin_thread_v2_dpi():
|
|
245
|
+
raise InputUnavailable("physical DPI coordinate context is unavailable")
|
|
246
|
+
self._require_foreground(window)
|
|
247
|
+
if not self._native.click(window.native_handle, point.x, point.y):
|
|
248
|
+
raise InputUnavailable(
|
|
249
|
+
"cursor placement or foreground validation failed"
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def scroll(
|
|
253
|
+
self, window: DesktopWindow, point: PixelPoint, amount: int
|
|
254
|
+
) -> None:
|
|
255
|
+
if not _pin_thread_v2_dpi():
|
|
256
|
+
raise InputUnavailable("physical DPI coordinate context is unavailable")
|
|
257
|
+
self._require_foreground(window)
|
|
258
|
+
if not self._native.scroll(
|
|
259
|
+
window.native_handle, point.x, point.y, amount * 80
|
|
260
|
+
):
|
|
261
|
+
raise InputUnavailable(
|
|
262
|
+
"cursor placement or foreground validation failed"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def paste_and_submit(
|
|
266
|
+
self, window: DesktopWindow, point: PixelPoint, text: str
|
|
267
|
+
) -> None:
|
|
268
|
+
self.click(window, point)
|
|
269
|
+
self._native.set_clipboard_text(text)
|
|
270
|
+
self._require_foreground(window)
|
|
271
|
+
if not self._native.send_paste_and_submit(window.native_handle):
|
|
272
|
+
raise InputUnavailable("input injection failed")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class _WindowsNative:
|
|
276
|
+
"""The only class that holds Win32 or UI Automation objects."""
|
|
277
|
+
|
|
278
|
+
def __init__(self) -> None:
|
|
279
|
+
self._controls: dict[str, Any] = {}
|
|
280
|
+
|
|
281
|
+
def list_windows(self) -> list[_NativeWindow]:
|
|
282
|
+
import ctypes
|
|
283
|
+
from ctypes import wintypes
|
|
284
|
+
|
|
285
|
+
user32 = ctypes.windll.user32
|
|
286
|
+
windows: list[_NativeWindow] = []
|
|
287
|
+
|
|
288
|
+
@ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
|
|
289
|
+
def visit(native_handle: int, _: int) -> bool:
|
|
290
|
+
if not user32.IsWindowVisible(native_handle):
|
|
291
|
+
return True
|
|
292
|
+
rect = wintypes.RECT()
|
|
293
|
+
if not user32.GetWindowRect(native_handle, ctypes.byref(rect)):
|
|
294
|
+
return True
|
|
295
|
+
width = rect.right - rect.left
|
|
296
|
+
height = rect.bottom - rect.top
|
|
297
|
+
if width <= 0 or height <= 0:
|
|
298
|
+
return True
|
|
299
|
+
title_length = user32.GetWindowTextLengthW(native_handle)
|
|
300
|
+
title_buffer = ctypes.create_unicode_buffer(title_length + 1)
|
|
301
|
+
user32.GetWindowTextW(native_handle, title_buffer, len(title_buffer))
|
|
302
|
+
process_id = wintypes.DWORD()
|
|
303
|
+
user32.GetWindowThreadProcessId(native_handle, ctypes.byref(process_id))
|
|
304
|
+
windows.append(
|
|
305
|
+
_NativeWindow(
|
|
306
|
+
int(native_handle),
|
|
307
|
+
title_buffer.value,
|
|
308
|
+
self._process_path(process_id.value),
|
|
309
|
+
PixelRegion(rect.left, rect.top, width, height),
|
|
310
|
+
True,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
return True
|
|
314
|
+
|
|
315
|
+
user32.EnumWindows(visit, 0)
|
|
316
|
+
return windows
|
|
317
|
+
|
|
318
|
+
def _process_path(self, process_id: int) -> str:
|
|
319
|
+
import ctypes
|
|
320
|
+
from ctypes import wintypes
|
|
321
|
+
|
|
322
|
+
kernel32 = ctypes.windll.kernel32
|
|
323
|
+
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
324
|
+
process_handle = kernel32.OpenProcess(0x1000, False, process_id)
|
|
325
|
+
if not process_handle:
|
|
326
|
+
return ""
|
|
327
|
+
try:
|
|
328
|
+
path = ctypes.create_unicode_buffer(32768)
|
|
329
|
+
path_size = wintypes.DWORD(len(path))
|
|
330
|
+
if not kernel32.QueryFullProcessImageNameW(
|
|
331
|
+
process_handle, 0, path, ctypes.byref(path_size)
|
|
332
|
+
):
|
|
333
|
+
return ""
|
|
334
|
+
return path.value
|
|
335
|
+
finally:
|
|
336
|
+
kernel32.CloseHandle(process_handle)
|
|
337
|
+
|
|
338
|
+
def activate(self, native_handle: int) -> bool:
|
|
339
|
+
import ctypes
|
|
340
|
+
from ctypes import wintypes
|
|
341
|
+
|
|
342
|
+
user32 = ctypes.windll.user32
|
|
343
|
+
kernel32 = ctypes.windll.kernel32
|
|
344
|
+
user32.GetForegroundWindow.restype = ctypes.c_void_p
|
|
345
|
+
if not user32.IsWindow(native_handle):
|
|
346
|
+
return False
|
|
347
|
+
foreground_handle = user32.GetForegroundWindow()
|
|
348
|
+
foreground_thread = wintypes.DWORD()
|
|
349
|
+
if foreground_handle:
|
|
350
|
+
foreground_thread_id = user32.GetWindowThreadProcessId(
|
|
351
|
+
foreground_handle, ctypes.byref(foreground_thread)
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
foreground_thread_id = 0
|
|
355
|
+
current_thread_id = kernel32.GetCurrentThreadId()
|
|
356
|
+
attached = bool(
|
|
357
|
+
foreground_thread_id
|
|
358
|
+
and foreground_thread_id != current_thread_id
|
|
359
|
+
and user32.AttachThreadInput(foreground_thread_id, current_thread_id, True)
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
user32.SetForegroundWindow(native_handle)
|
|
363
|
+
return user32.GetForegroundWindow() == native_handle
|
|
364
|
+
finally:
|
|
365
|
+
if attached:
|
|
366
|
+
user32.AttachThreadInput(foreground_thread_id, current_thread_id, False)
|
|
367
|
+
|
|
368
|
+
def is_foreground(self, native_handle: int) -> bool:
|
|
369
|
+
import ctypes
|
|
370
|
+
|
|
371
|
+
user32 = ctypes.windll.user32
|
|
372
|
+
user32.GetForegroundWindow.restype = ctypes.c_void_p
|
|
373
|
+
return user32.GetForegroundWindow() == native_handle
|
|
374
|
+
|
|
375
|
+
def capture(self, bounding_box: tuple[int, int, int, int]) -> Image.Image:
|
|
376
|
+
return ImageGrab.grab(bbox=bounding_box, all_screens=True)
|
|
377
|
+
|
|
378
|
+
def accessibility_tree(self, native_handle: int) -> list[_NativeControl]:
|
|
379
|
+
import uiautomation as auto
|
|
380
|
+
|
|
381
|
+
self._controls = {}
|
|
382
|
+
controls: list[_NativeControl] = []
|
|
383
|
+
|
|
384
|
+
def walk(control: Any, depth: int) -> None:
|
|
385
|
+
if depth > 40:
|
|
386
|
+
return
|
|
387
|
+
if not self._is_offscreen(control):
|
|
388
|
+
region = self._control_region(control)
|
|
389
|
+
if region is not None:
|
|
390
|
+
control_id = f"control-{len(controls)}"
|
|
391
|
+
actions, expanded = self._capabilities(control, auto)
|
|
392
|
+
self._controls[control_id] = control
|
|
393
|
+
controls.append(
|
|
394
|
+
_NativeControl(
|
|
395
|
+
control_id,
|
|
396
|
+
str(getattr(control, "ControlTypeName", "")),
|
|
397
|
+
str(getattr(control, "Name", "")),
|
|
398
|
+
str(getattr(control, "ClassName", "")),
|
|
399
|
+
region,
|
|
400
|
+
depth,
|
|
401
|
+
expanded,
|
|
402
|
+
actions,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
for child in self._children(control):
|
|
406
|
+
walk(child, depth + 1)
|
|
407
|
+
|
|
408
|
+
walk(auto.ControlFromHandle(native_handle), 0)
|
|
409
|
+
return controls
|
|
410
|
+
|
|
411
|
+
@staticmethod
|
|
412
|
+
def _children(control: Any) -> list[Any]:
|
|
413
|
+
try:
|
|
414
|
+
return list(control.GetChildren())
|
|
415
|
+
except Exception:
|
|
416
|
+
return []
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def _is_offscreen(control: Any) -> bool:
|
|
420
|
+
try:
|
|
421
|
+
return bool(control.IsOffscreen)
|
|
422
|
+
except Exception:
|
|
423
|
+
return True
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _control_region(control: Any) -> PixelRegion | None:
|
|
427
|
+
try:
|
|
428
|
+
rectangle = control.BoundingRectangle
|
|
429
|
+
width = int(rectangle.right) - int(rectangle.left)
|
|
430
|
+
height = int(rectangle.bottom) - int(rectangle.top)
|
|
431
|
+
if width <= 0 or height <= 0:
|
|
432
|
+
return None
|
|
433
|
+
return PixelRegion(int(rectangle.left), int(rectangle.top), width, height)
|
|
434
|
+
except Exception:
|
|
435
|
+
return None
|
|
436
|
+
|
|
437
|
+
def _capabilities(
|
|
438
|
+
self, control: Any, auto: Any
|
|
439
|
+
) -> tuple[frozenset[AccessibilityAction], bool | None]:
|
|
440
|
+
actions: set[AccessibilityAction] = set()
|
|
441
|
+
if self._pattern(control, auto, "Invoke") is not None:
|
|
442
|
+
actions.add(AccessibilityAction.INVOKE)
|
|
443
|
+
expand_collapse = self._pattern(control, auto, "ExpandCollapse")
|
|
444
|
+
if expand_collapse is None:
|
|
445
|
+
return frozenset(actions), None
|
|
446
|
+
actions.update({AccessibilityAction.EXPAND, AccessibilityAction.COLLAPSE})
|
|
447
|
+
try:
|
|
448
|
+
state = expand_collapse.ExpandCollapseState
|
|
449
|
+
except Exception:
|
|
450
|
+
return frozenset(actions), None
|
|
451
|
+
state_name = str(state).casefold()
|
|
452
|
+
if "expanded" in state_name or state == 1:
|
|
453
|
+
return frozenset(actions), True
|
|
454
|
+
if "collapsed" in state_name or state == 0:
|
|
455
|
+
return frozenset(actions), False
|
|
456
|
+
return frozenset(actions), None
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def _pattern(control: Any, auto: Any, name: str) -> Any | None:
|
|
460
|
+
getter = getattr(control, f"Get{name}Pattern", None)
|
|
461
|
+
if getter is not None:
|
|
462
|
+
try:
|
|
463
|
+
return getter()
|
|
464
|
+
except Exception:
|
|
465
|
+
pass
|
|
466
|
+
pattern_id = getattr(getattr(auto, "PatternId", object()), f"{name}Pattern", None)
|
|
467
|
+
get_pattern = getattr(control, "GetPattern", None)
|
|
468
|
+
if pattern_id is not None and get_pattern is not None:
|
|
469
|
+
try:
|
|
470
|
+
return get_pattern(pattern_id)
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
def accessibility_action(self, control_id: str, action: AccessibilityAction) -> None:
|
|
476
|
+
import uiautomation as auto
|
|
477
|
+
|
|
478
|
+
control = self._controls.get(control_id)
|
|
479
|
+
if control is None:
|
|
480
|
+
raise ValueError("accessibility control is unavailable")
|
|
481
|
+
pattern_name = (
|
|
482
|
+
"Invoke"
|
|
483
|
+
if action is AccessibilityAction.INVOKE
|
|
484
|
+
else "ExpandCollapse"
|
|
485
|
+
)
|
|
486
|
+
pattern = self._pattern(control, auto, pattern_name)
|
|
487
|
+
if pattern is None:
|
|
488
|
+
raise ValueError(f"accessibility action {action.value!r} is unavailable")
|
|
489
|
+
method_name = {
|
|
490
|
+
AccessibilityAction.INVOKE: "Invoke",
|
|
491
|
+
AccessibilityAction.EXPAND: "Expand",
|
|
492
|
+
AccessibilityAction.COLLAPSE: "Collapse",
|
|
493
|
+
}[action]
|
|
494
|
+
getattr(pattern, method_name)()
|
|
495
|
+
|
|
496
|
+
def click(self, native_handle: int, x: int, y: int) -> bool:
|
|
497
|
+
import ctypes
|
|
498
|
+
|
|
499
|
+
user32 = ctypes.windll.user32
|
|
500
|
+
if not user32.SetCursorPos(x, y) or not self.is_foreground(native_handle):
|
|
501
|
+
return False
|
|
502
|
+
user32.mouse_event(0x0002, 0, 0, 0, 0) # MOUSEEVENTF_LEFTDOWN
|
|
503
|
+
user32.mouse_event(0x0004, 0, 0, 0, 0) # MOUSEEVENTF_LEFTUP
|
|
504
|
+
return True
|
|
505
|
+
|
|
506
|
+
def scroll(self, native_handle: int, x: int, y: int, wheel_data: int) -> bool:
|
|
507
|
+
import ctypes
|
|
508
|
+
|
|
509
|
+
user32 = ctypes.windll.user32
|
|
510
|
+
if not user32.SetCursorPos(x, y) or not self.is_foreground(native_handle):
|
|
511
|
+
return False
|
|
512
|
+
user32.mouse_event(0x0800, 0, 0, wheel_data, 0) # MOUSEEVENTF_WHEEL
|
|
513
|
+
return True
|
|
514
|
+
|
|
515
|
+
def set_clipboard_text(self, text: str) -> None:
|
|
516
|
+
self._set_clipboard_text(text)
|
|
517
|
+
|
|
518
|
+
def send_paste_and_submit(self, native_handle: int) -> bool:
|
|
519
|
+
import ctypes
|
|
520
|
+
from ctypes import wintypes
|
|
521
|
+
|
|
522
|
+
class MouseInput(ctypes.Structure):
|
|
523
|
+
_fields_ = [
|
|
524
|
+
("dx", wintypes.LONG),
|
|
525
|
+
("dy", wintypes.LONG),
|
|
526
|
+
("mouseData", wintypes.DWORD),
|
|
527
|
+
("dwFlags", wintypes.DWORD),
|
|
528
|
+
("time", wintypes.DWORD),
|
|
529
|
+
("dwExtraInfo", ctypes.c_size_t),
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
class KeyboardInput(ctypes.Structure):
|
|
533
|
+
_fields_ = [
|
|
534
|
+
("wVk", wintypes.WORD),
|
|
535
|
+
("wScan", wintypes.WORD),
|
|
536
|
+
("dwFlags", wintypes.DWORD),
|
|
537
|
+
("time", wintypes.DWORD),
|
|
538
|
+
("dwExtraInfo", ctypes.c_size_t),
|
|
539
|
+
]
|
|
540
|
+
|
|
541
|
+
class InputUnion(ctypes.Union):
|
|
542
|
+
_fields_ = [("mi", MouseInput), ("ki", KeyboardInput)]
|
|
543
|
+
|
|
544
|
+
class Input(ctypes.Structure):
|
|
545
|
+
_anonymous_ = ("input",)
|
|
546
|
+
_fields_ = [("type", wintypes.DWORD), ("input", InputUnion)]
|
|
547
|
+
|
|
548
|
+
def keyboard_input(virtual_key: int, flags: int = 0) -> Input:
|
|
549
|
+
event = Input()
|
|
550
|
+
event.type = 1 # INPUT_KEYBOARD
|
|
551
|
+
event.ki = KeyboardInput(virtual_key, 0, flags, 0, 0)
|
|
552
|
+
return event
|
|
553
|
+
|
|
554
|
+
if not self.is_foreground(native_handle):
|
|
555
|
+
return False
|
|
556
|
+
user32 = ctypes.windll.user32
|
|
557
|
+
user32.SendInput.argtypes = (wintypes.UINT, ctypes.POINTER(Input), ctypes.c_int)
|
|
558
|
+
user32.SendInput.restype = wintypes.UINT
|
|
559
|
+
|
|
560
|
+
def send_inputs(*events: Input) -> int:
|
|
561
|
+
inputs = (Input * len(events))(*events)
|
|
562
|
+
return user32.SendInput(len(inputs), inputs, ctypes.sizeof(Input))
|
|
563
|
+
|
|
564
|
+
def drain_key_releases(*releases: Input) -> bool:
|
|
565
|
+
all_released = True
|
|
566
|
+
for release in releases:
|
|
567
|
+
all_released = send_inputs(release) == 1 and all_released
|
|
568
|
+
return all_released
|
|
569
|
+
|
|
570
|
+
paste = (
|
|
571
|
+
keyboard_input(0x11), # VK_CONTROL
|
|
572
|
+
keyboard_input(0x56), # VK_V
|
|
573
|
+
keyboard_input(0x56, 0x0002), # KEYEVENTF_KEYUP
|
|
574
|
+
keyboard_input(0x11, 0x0002), # KEYEVENTF_KEYUP
|
|
575
|
+
)
|
|
576
|
+
paste_sent = send_inputs(*paste)
|
|
577
|
+
if paste_sent != len(paste):
|
|
578
|
+
key_releases = {
|
|
579
|
+
1: (keyboard_input(0x11, 0x0002),),
|
|
580
|
+
2: (keyboard_input(0x56, 0x0002), keyboard_input(0x11, 0x0002)),
|
|
581
|
+
3: (keyboard_input(0x11, 0x0002),),
|
|
582
|
+
}.get(paste_sent, ())
|
|
583
|
+
if key_releases:
|
|
584
|
+
drain_key_releases(*key_releases)
|
|
585
|
+
return False
|
|
586
|
+
if not self.is_foreground(native_handle):
|
|
587
|
+
return False
|
|
588
|
+
enter = (
|
|
589
|
+
keyboard_input(0x0D), # VK_RETURN
|
|
590
|
+
keyboard_input(0x0D, 0x0002), # KEYEVENTF_KEYUP
|
|
591
|
+
)
|
|
592
|
+
enter_sent = send_inputs(*enter)
|
|
593
|
+
if enter_sent != len(enter):
|
|
594
|
+
if enter_sent == 1:
|
|
595
|
+
drain_key_releases(keyboard_input(0x0D, 0x0002))
|
|
596
|
+
return False
|
|
597
|
+
return True
|
|
598
|
+
|
|
599
|
+
@staticmethod
|
|
600
|
+
def _set_clipboard_text(text: str) -> None:
|
|
601
|
+
import ctypes
|
|
602
|
+
from ctypes import wintypes
|
|
603
|
+
|
|
604
|
+
user32 = ctypes.windll.user32
|
|
605
|
+
kernel32 = ctypes.windll.kernel32
|
|
606
|
+
hglobal = ctypes.c_void_p
|
|
607
|
+
user32.OpenClipboard.argtypes = (wintypes.HWND,)
|
|
608
|
+
user32.OpenClipboard.restype = wintypes.BOOL
|
|
609
|
+
user32.EmptyClipboard.argtypes = ()
|
|
610
|
+
user32.EmptyClipboard.restype = wintypes.BOOL
|
|
611
|
+
user32.SetClipboardData.argtypes = (wintypes.UINT, hglobal)
|
|
612
|
+
user32.SetClipboardData.restype = hglobal
|
|
613
|
+
user32.CloseClipboard.argtypes = ()
|
|
614
|
+
user32.CloseClipboard.restype = wintypes.BOOL
|
|
615
|
+
kernel32.GlobalAlloc.argtypes = (wintypes.UINT, ctypes.c_size_t)
|
|
616
|
+
kernel32.GlobalAlloc.restype = hglobal
|
|
617
|
+
kernel32.GlobalLock.argtypes = (hglobal,)
|
|
618
|
+
kernel32.GlobalLock.restype = ctypes.c_void_p
|
|
619
|
+
kernel32.GlobalUnlock.argtypes = (hglobal,)
|
|
620
|
+
kernel32.GlobalUnlock.restype = wintypes.BOOL
|
|
621
|
+
kernel32.GlobalFree.argtypes = (hglobal,)
|
|
622
|
+
kernel32.GlobalFree.restype = hglobal
|
|
623
|
+
if not user32.OpenClipboard(None):
|
|
624
|
+
raise OSError("could not open the clipboard")
|
|
625
|
+
try:
|
|
626
|
+
if not user32.EmptyClipboard():
|
|
627
|
+
raise OSError("could not clear the clipboard")
|
|
628
|
+
value = ctypes.create_unicode_buffer(text)
|
|
629
|
+
memory = kernel32.GlobalAlloc(0x0002, ctypes.sizeof(value))
|
|
630
|
+
if not memory:
|
|
631
|
+
raise OSError("could not allocate clipboard memory")
|
|
632
|
+
pointer = kernel32.GlobalLock(memory)
|
|
633
|
+
if not pointer:
|
|
634
|
+
kernel32.GlobalFree(memory)
|
|
635
|
+
raise OSError("could not lock clipboard memory")
|
|
636
|
+
try:
|
|
637
|
+
ctypes.memmove(pointer, value, ctypes.sizeof(value))
|
|
638
|
+
finally:
|
|
639
|
+
kernel32.GlobalUnlock(memory)
|
|
640
|
+
if not user32.SetClipboardData(13, memory): # CF_UNICODETEXT
|
|
641
|
+
kernel32.GlobalFree(memory)
|
|
642
|
+
raise OSError("could not set clipboard text")
|
|
643
|
+
finally:
|
|
644
|
+
user32.CloseClipboard()
|