windows-mcp 0.5.8__py3-none-any.whl → 0.6.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.
- windows_mcp/__main__.py +299 -314
- windows_mcp/analytics.py +0 -5
- windows_mcp/desktop/service.py +638 -458
- windows_mcp/desktop/views.py +7 -5
- windows_mcp/tree/cache_utils.py +126 -0
- windows_mcp/tree/config.py +25 -0
- windows_mcp/tree/service.py +543 -601
- windows_mcp/tree/views.py +142 -116
- windows_mcp/uia/controls.py +11 -2
- windows_mcp/uia/core.py +9 -0
- windows_mcp/vdm/__init__.py +1 -0
- windows_mcp/vdm/core.py +490 -0
- windows_mcp/watchdog/event_handlers.py +13 -9
- windows_mcp/watchdog/service.py +15 -4
- {windows_mcp-0.5.8.dist-info → windows_mcp-0.6.0.dist-info}/METADATA +27 -21
- windows_mcp-0.6.0.dist-info/RECORD +29 -0
- windows_mcp-0.5.8.dist-info/RECORD +0 -26
- {windows_mcp-0.5.8.dist-info → windows_mcp-0.6.0.dist-info}/WHEEL +0 -0
- {windows_mcp-0.5.8.dist-info → windows_mcp-0.6.0.dist-info}/entry_points.txt +0 -0
- {windows_mcp-0.5.8.dist-info → windows_mcp-0.6.0.dist-info}/licenses/LICENSE.md +0 -0
windows_mcp/desktop/service.py
CHANGED
|
@@ -1,458 +1,638 @@
|
|
|
1
|
-
from windows_mcp.desktop.config import BROWSER_NAMES, PROCESS_PER_MONITOR_DPI_AWARE
|
|
2
|
-
from windows_mcp.desktop.views import DesktopState, App,
|
|
3
|
-
from windows_mcp.tree.
|
|
4
|
-
from
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
import
|
|
22
|
-
import
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
import
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
pg.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
self.
|
|
46
|
-
self.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
active_app
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return Status.
|
|
95
|
-
elif uia.
|
|
96
|
-
return Status.
|
|
97
|
-
|
|
98
|
-
return Status.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return
|
|
145
|
-
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
return f
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
pg.
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return
|
|
409
|
-
|
|
410
|
-
def
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
return
|
|
434
|
-
|
|
435
|
-
def
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1
|
+
from windows_mcp.desktop.config import BROWSER_NAMES, PROCESS_PER_MONITOR_DPI_AWARE
|
|
2
|
+
from windows_mcp.desktop.views import DesktopState, App, Status, Size
|
|
3
|
+
from windows_mcp.tree.views import BoundingBox, TreeElementNode
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
from PIL import ImageGrab, ImageFont, ImageDraw, Image
|
|
6
|
+
from windows_mcp.tree.service import Tree
|
|
7
|
+
from locale import getpreferredencoding
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Optional,Literal
|
|
10
|
+
from markdownify import markdownify
|
|
11
|
+
from fuzzywuzzy import process
|
|
12
|
+
from time import sleep,time
|
|
13
|
+
from psutil import Process
|
|
14
|
+
import win32process
|
|
15
|
+
import subprocess
|
|
16
|
+
import win32gui
|
|
17
|
+
import win32con
|
|
18
|
+
import requests
|
|
19
|
+
import logging
|
|
20
|
+
import base64
|
|
21
|
+
import ctypes
|
|
22
|
+
import csv
|
|
23
|
+
import re
|
|
24
|
+
import os
|
|
25
|
+
import io
|
|
26
|
+
import random
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
logger.setLevel(logging.INFO)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
ctypes.windll.shcore.SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE)
|
|
33
|
+
except Exception:
|
|
34
|
+
ctypes.windll.user32.SetProcessDPIAware()
|
|
35
|
+
|
|
36
|
+
import windows_mcp.uia as uia
|
|
37
|
+
import pyautogui as pg
|
|
38
|
+
|
|
39
|
+
pg.FAILSAFE=False
|
|
40
|
+
pg.PAUSE=1.0
|
|
41
|
+
|
|
42
|
+
class Desktop:
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.encoding=getpreferredencoding()
|
|
45
|
+
self.tree=Tree(self)
|
|
46
|
+
self.desktop_state=None
|
|
47
|
+
|
|
48
|
+
def get_state(self,use_annotation:bool=True,use_vision:bool=False,use_dom:bool=False,as_bytes:bool=False,scale:float=1.0)->DesktopState:
|
|
49
|
+
sleep(0.1)
|
|
50
|
+
start_time = time()
|
|
51
|
+
|
|
52
|
+
controls_handles=self.get_controls_handles() # Taskbar,Program Manager,Apps, Dialogs
|
|
53
|
+
apps,apps_handles=self.get_apps(controls_handles=controls_handles) # Apps
|
|
54
|
+
active_app=self.get_active_app(apps=apps) #Active App
|
|
55
|
+
active_app_handle=active_app.handle if active_app else None
|
|
56
|
+
|
|
57
|
+
if active_app is not None and active_app in apps:
|
|
58
|
+
apps.remove(active_app)
|
|
59
|
+
|
|
60
|
+
logger.debug(f"Active app: {active_app or 'No Active App Found'}")
|
|
61
|
+
logger.debug(f"Apps: {apps}")
|
|
62
|
+
|
|
63
|
+
#Preparing handles for Tree
|
|
64
|
+
other_apps_handles=list(controls_handles-apps_handles)
|
|
65
|
+
|
|
66
|
+
tree_state=self.tree.get_state(active_app_handle,other_apps_handles,use_dom=use_dom)
|
|
67
|
+
|
|
68
|
+
if use_vision:
|
|
69
|
+
if use_annotation:
|
|
70
|
+
nodes=tree_state.interactive_nodes
|
|
71
|
+
screenshot=self.get_annotated_screenshot(nodes=nodes)
|
|
72
|
+
else:
|
|
73
|
+
screenshot=self.get_screenshot()
|
|
74
|
+
|
|
75
|
+
if scale != 1.0:
|
|
76
|
+
screenshot = screenshot.resize((int(screenshot.width * scale), int(screenshot.height * scale)), Image.LANCZOS)
|
|
77
|
+
|
|
78
|
+
if as_bytes:
|
|
79
|
+
buffered = io.BytesIO()
|
|
80
|
+
screenshot.save(buffered, format="PNG")
|
|
81
|
+
screenshot = buffered.getvalue()
|
|
82
|
+
buffered.close()
|
|
83
|
+
else:
|
|
84
|
+
screenshot=None
|
|
85
|
+
|
|
86
|
+
self.desktop_state=DesktopState(apps= apps,active_app=active_app,screenshot=screenshot,tree_state=tree_state)
|
|
87
|
+
# Log the time taken to capture the state
|
|
88
|
+
end_time = time()
|
|
89
|
+
logger.info(f"Desktop State capture took {end_time - start_time:.2f} seconds")
|
|
90
|
+
return self.desktop_state
|
|
91
|
+
|
|
92
|
+
def get_app_status(self,control:uia.Control)->Status:
|
|
93
|
+
if uia.IsIconic(control.NativeWindowHandle):
|
|
94
|
+
return Status.MINIMIZED
|
|
95
|
+
elif uia.IsZoomed(control.NativeWindowHandle):
|
|
96
|
+
return Status.MAXIMIZED
|
|
97
|
+
elif uia.IsWindowVisible(control.NativeWindowHandle):
|
|
98
|
+
return Status.NORMAL
|
|
99
|
+
else:
|
|
100
|
+
return Status.HIDDEN
|
|
101
|
+
|
|
102
|
+
def get_cursor_location(self)->tuple[int,int]:
|
|
103
|
+
position=pg.position()
|
|
104
|
+
return (position.x,position.y)
|
|
105
|
+
|
|
106
|
+
def get_element_under_cursor(self)->uia.Control:
|
|
107
|
+
return uia.ControlFromCursor()
|
|
108
|
+
|
|
109
|
+
def get_apps_from_start_menu(self)->dict[str,str]:
|
|
110
|
+
command='Get-StartApps | ConvertTo-Csv -NoTypeInformation'
|
|
111
|
+
apps_info, status = self.execute_command(command)
|
|
112
|
+
|
|
113
|
+
if status != 0 or not apps_info:
|
|
114
|
+
logger.error(f"Failed to get apps from start menu: {apps_info}")
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
reader = csv.DictReader(io.StringIO(apps_info.strip()))
|
|
119
|
+
return {
|
|
120
|
+
row.get('Name').lower(): row.get('AppID')
|
|
121
|
+
for row in reader
|
|
122
|
+
if row.get('Name') and row.get('AppID')
|
|
123
|
+
}
|
|
124
|
+
except Exception as e:
|
|
125
|
+
logger.error(f"Error parsing start menu apps: {e}")
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
def execute_command(self, command: str,timeout:int=10) -> tuple[str, int]:
|
|
129
|
+
try:
|
|
130
|
+
encoded = base64.b64encode(command.encode("utf-16le")).decode("ascii")
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
['powershell', '-NoProfile', '-EncodedCommand', encoded],
|
|
133
|
+
capture_output=True, # No errors='ignore' - let subprocess return bytes
|
|
134
|
+
timeout=timeout,
|
|
135
|
+
cwd=os.path.expanduser(path='~')
|
|
136
|
+
)
|
|
137
|
+
# Handle both bytes and str output (subprocess behavior varies by environment)
|
|
138
|
+
stdout = result.stdout
|
|
139
|
+
stderr = result.stderr
|
|
140
|
+
if isinstance(stdout, bytes):
|
|
141
|
+
stdout = stdout.decode(self.encoding, errors='ignore')
|
|
142
|
+
if isinstance(stderr, bytes):
|
|
143
|
+
stderr = stderr.decode(self.encoding, errors='ignore')
|
|
144
|
+
return (stdout or stderr, result.returncode)
|
|
145
|
+
except subprocess.TimeoutExpired:
|
|
146
|
+
return ('Command execution timed out', 1)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
return (f'Command execution failed: {type(e).__name__}: {e}', 1)
|
|
149
|
+
|
|
150
|
+
def is_app_browser(self,node:uia.Control):
|
|
151
|
+
'''Give any node of the app and it will return True if the app is a browser, False otherwise.'''
|
|
152
|
+
try:
|
|
153
|
+
process=Process(node.ProcessId)
|
|
154
|
+
return process.name() in BROWSER_NAMES
|
|
155
|
+
except:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def get_default_language(self)->str:
|
|
159
|
+
command="Get-Culture | Select-Object Name,DisplayName | ConvertTo-Csv -NoTypeInformation"
|
|
160
|
+
response,_=self.execute_command(command)
|
|
161
|
+
reader=csv.DictReader(io.StringIO(response))
|
|
162
|
+
return "".join([row.get('DisplayName') for row in reader])
|
|
163
|
+
|
|
164
|
+
def resize_app(self,size:tuple[int,int]=None,loc:tuple[int,int]=None)->tuple[str,int]:
|
|
165
|
+
active_app=self.desktop_state.active_app
|
|
166
|
+
if active_app is None:
|
|
167
|
+
return "No active app found",1
|
|
168
|
+
if active_app.status==Status.MINIMIZED:
|
|
169
|
+
return f"{active_app.name} is minimized",1
|
|
170
|
+
elif active_app.status==Status.MAXIMIZED:
|
|
171
|
+
return f"{active_app.name} is maximized",1
|
|
172
|
+
else:
|
|
173
|
+
app_control=uia.ControlFromHandle(active_app.handle)
|
|
174
|
+
if loc is None:
|
|
175
|
+
x=app_control.BoundingRectangle.left
|
|
176
|
+
y=app_control.BoundingRectangle.top
|
|
177
|
+
loc=(x,y)
|
|
178
|
+
if size is None:
|
|
179
|
+
width=app_control.BoundingRectangle.width()
|
|
180
|
+
height=app_control.BoundingRectangle.height()
|
|
181
|
+
size=(width,height)
|
|
182
|
+
x,y=loc
|
|
183
|
+
width,height=size
|
|
184
|
+
app_control.MoveWindow(x,y,width,height)
|
|
185
|
+
return (f'{active_app.name} resized to {width}x{height} at {x},{y}.',0)
|
|
186
|
+
|
|
187
|
+
def is_app_running(self,name:str)->bool:
|
|
188
|
+
apps, _ = self.get_apps()
|
|
189
|
+
apps_dict = {app.name: app for app in apps}
|
|
190
|
+
return process.extractOne(name,list(apps_dict.keys()),score_cutoff=60) is not None
|
|
191
|
+
|
|
192
|
+
def app(self,mode:Literal['launch','switch','resize'],name:Optional[str]=None,loc:Optional[tuple[int,int]]=None,size:Optional[tuple[int,int]]=None):
|
|
193
|
+
match mode:
|
|
194
|
+
case 'launch':
|
|
195
|
+
response,status,pid=self.launch_app(name)
|
|
196
|
+
if status!=0:
|
|
197
|
+
return response
|
|
198
|
+
|
|
199
|
+
# Smart wait using UIA Exists (avoids manual Python loops)
|
|
200
|
+
launched = False
|
|
201
|
+
if pid > 0:
|
|
202
|
+
if uia.WindowControl(ProcessId=pid).Exists(maxSearchSeconds=10):
|
|
203
|
+
launched = True
|
|
204
|
+
|
|
205
|
+
if not launched:
|
|
206
|
+
# Fallback: Regex search for the window title
|
|
207
|
+
safe_name = re.escape(name)
|
|
208
|
+
if uia.WindowControl(RegexName=f'(?i).*{safe_name}.*').Exists(maxSearchSeconds=10):
|
|
209
|
+
launched = True
|
|
210
|
+
|
|
211
|
+
if launched:
|
|
212
|
+
return f'{name.title()} launched.'
|
|
213
|
+
return f'Launching {name.title()} sent, but window not detected yet.'
|
|
214
|
+
case 'resize':
|
|
215
|
+
response,status=self.resize_app(size=size,loc=loc)
|
|
216
|
+
if status!=0:
|
|
217
|
+
return response
|
|
218
|
+
else:
|
|
219
|
+
return response
|
|
220
|
+
case 'switch':
|
|
221
|
+
response,status=self.switch_app(name)
|
|
222
|
+
if status!=0:
|
|
223
|
+
return response
|
|
224
|
+
else:
|
|
225
|
+
return response
|
|
226
|
+
|
|
227
|
+
def launch_app(self,name:str)->tuple[str,int,int]:
|
|
228
|
+
apps_map=self.get_apps_from_start_menu()
|
|
229
|
+
matched_app=process.extractOne(name,apps_map.keys(),score_cutoff=70)
|
|
230
|
+
if matched_app is None:
|
|
231
|
+
return (f'{name.title()} not found in start menu.',1,0)
|
|
232
|
+
app_name,_=matched_app
|
|
233
|
+
appid=apps_map.get(app_name)
|
|
234
|
+
if appid is None:
|
|
235
|
+
return (name,f'{name.title()} not found in start menu.',1,0)
|
|
236
|
+
|
|
237
|
+
pid = 0
|
|
238
|
+
if os.path.exists(appid) or "\\" in appid:
|
|
239
|
+
# It's a file path, we can try to get the PID using PassThru
|
|
240
|
+
command = f'Start-Process "{appid}" -PassThru | Select-Object -ExpandProperty Id'
|
|
241
|
+
response, status = self.execute_command(command)
|
|
242
|
+
if status == 0 and response.strip().isdigit():
|
|
243
|
+
pid = int(response.strip())
|
|
244
|
+
else:
|
|
245
|
+
# It's an AUMID (Store App)
|
|
246
|
+
command = f'Start-Process "shell:AppsFolder\\{appid}"'
|
|
247
|
+
response, status = self.execute_command(command)
|
|
248
|
+
|
|
249
|
+
return response, status, pid
|
|
250
|
+
|
|
251
|
+
def switch_app(self,name:str):
|
|
252
|
+
apps={app.name:app for app in [self.desktop_state.active_app]+self.desktop_state.apps if app is not None}
|
|
253
|
+
matched_app:Optional[tuple[str,float]]=process.extractOne(name,list(apps.keys()),score_cutoff=70)
|
|
254
|
+
if matched_app is None:
|
|
255
|
+
return (f'Application {name.title()} not found.',1)
|
|
256
|
+
app_name,_=matched_app
|
|
257
|
+
app=apps.get(app_name)
|
|
258
|
+
target_handle=app.handle
|
|
259
|
+
|
|
260
|
+
if uia.IsIconic(target_handle):
|
|
261
|
+
uia.ShowWindow(target_handle, win32con.SW_RESTORE)
|
|
262
|
+
content=f'{app_name.title()} restored from Minimized state.'
|
|
263
|
+
else:
|
|
264
|
+
self.bring_window_to_top(target_handle)
|
|
265
|
+
content=f'Switched to {app_name.title()} window.'
|
|
266
|
+
return content,0
|
|
267
|
+
|
|
268
|
+
def bring_window_to_top(self, target_handle: int):
|
|
269
|
+
if not win32gui.IsWindow(target_handle):
|
|
270
|
+
raise ValueError("Invalid window handle")
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
if win32gui.IsIconic(target_handle):
|
|
274
|
+
win32gui.ShowWindow(target_handle, win32con.SW_RESTORE)
|
|
275
|
+
|
|
276
|
+
foreground_handle = win32gui.GetForegroundWindow()
|
|
277
|
+
foreground_thread, _ = win32process.GetWindowThreadProcessId(foreground_handle)
|
|
278
|
+
target_thread, _ = win32process.GetWindowThreadProcessId(target_handle)
|
|
279
|
+
|
|
280
|
+
if not foreground_thread or not target_thread or foreground_thread == target_thread:
|
|
281
|
+
win32gui.SetForegroundWindow(target_handle)
|
|
282
|
+
win32gui.BringWindowToTop(target_handle)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
ctypes.windll.user32.AllowSetForegroundWindow(-1)
|
|
286
|
+
|
|
287
|
+
attached = False
|
|
288
|
+
try:
|
|
289
|
+
win32process.AttachThreadInput(foreground_thread, target_thread, True)
|
|
290
|
+
attached = True
|
|
291
|
+
|
|
292
|
+
win32gui.SetForegroundWindow(target_handle)
|
|
293
|
+
win32gui.BringWindowToTop(target_handle)
|
|
294
|
+
|
|
295
|
+
win32gui.SetWindowPos(
|
|
296
|
+
target_handle,
|
|
297
|
+
win32con.HWND_TOP,
|
|
298
|
+
0, 0, 0, 0,
|
|
299
|
+
win32con.SWP_NOMOVE | win32con.SWP_NOSIZE | win32con.SWP_SHOWWINDOW
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
finally:
|
|
303
|
+
if attached:
|
|
304
|
+
win32process.AttachThreadInput(foreground_thread, target_thread, False)
|
|
305
|
+
|
|
306
|
+
except Exception as e:
|
|
307
|
+
logger.exception(f"Failed to bring window to top: {e}")
|
|
308
|
+
|
|
309
|
+
def get_element_handle_from_label(self,label:int)->uia.Control:
|
|
310
|
+
tree_state=self.desktop_state.tree_state
|
|
311
|
+
element_node=tree_state.interactive_nodes[label]
|
|
312
|
+
xpath=element_node.xpath
|
|
313
|
+
element_handle=self.get_element_from_xpath(xpath)
|
|
314
|
+
return element_handle
|
|
315
|
+
|
|
316
|
+
def get_coordinates_from_label(self,label:int)->tuple[int,int]:
|
|
317
|
+
element_handle=self.get_element_handle_from_label(label)
|
|
318
|
+
bounding_rectangle=element_handle.BoundingRectangle
|
|
319
|
+
return bounding_rectangle.xcenter(),bounding_rectangle.ycenter()
|
|
320
|
+
|
|
321
|
+
def click(self,loc:tuple[int,int],button:str='left',clicks:int=2):
|
|
322
|
+
x,y=loc
|
|
323
|
+
pg.click(x,y,button=button,clicks=clicks,duration=0.1)
|
|
324
|
+
|
|
325
|
+
def type(self,loc:tuple[int,int],text:str,caret_position:Literal['start','end','none']='none',clear:Literal['true','false']='false',press_enter:Literal['true','false']='false'):
|
|
326
|
+
x,y=loc
|
|
327
|
+
pg.leftClick(x,y)
|
|
328
|
+
if caret_position == 'start':
|
|
329
|
+
pg.press('home')
|
|
330
|
+
elif caret_position == 'end':
|
|
331
|
+
pg.press('end')
|
|
332
|
+
else:
|
|
333
|
+
pass
|
|
334
|
+
if clear=='true':
|
|
335
|
+
pg.sleep(0.5)
|
|
336
|
+
pg.hotkey('ctrl','a')
|
|
337
|
+
pg.press('backspace')
|
|
338
|
+
pg.typewrite(text,interval=0.02)
|
|
339
|
+
if press_enter=='true':
|
|
340
|
+
pg.press('enter')
|
|
341
|
+
|
|
342
|
+
def scroll(self,loc:tuple[int,int]=None,type:Literal['horizontal','vertical']='vertical',direction:Literal['up','down','left','right']='down',wheel_times:int=1)->str|None:
|
|
343
|
+
if loc:
|
|
344
|
+
self.move(loc)
|
|
345
|
+
match type:
|
|
346
|
+
case 'vertical':
|
|
347
|
+
match direction:
|
|
348
|
+
case 'up':
|
|
349
|
+
uia.WheelUp(wheel_times)
|
|
350
|
+
case 'down':
|
|
351
|
+
uia.WheelDown(wheel_times)
|
|
352
|
+
case _:
|
|
353
|
+
return 'Invalid direction. Use "up" or "down".'
|
|
354
|
+
case 'horizontal':
|
|
355
|
+
match direction:
|
|
356
|
+
case 'left':
|
|
357
|
+
pg.keyDown('Shift')
|
|
358
|
+
pg.sleep(0.05)
|
|
359
|
+
uia.WheelUp(wheel_times)
|
|
360
|
+
pg.sleep(0.05)
|
|
361
|
+
pg.keyUp('Shift')
|
|
362
|
+
case 'right':
|
|
363
|
+
pg.keyDown('Shift')
|
|
364
|
+
pg.sleep(0.05)
|
|
365
|
+
uia.WheelDown(wheel_times)
|
|
366
|
+
pg.sleep(0.05)
|
|
367
|
+
pg.keyUp('Shift')
|
|
368
|
+
case _:
|
|
369
|
+
return 'Invalid direction. Use "left" or "right".'
|
|
370
|
+
case _:
|
|
371
|
+
return 'Invalid type. Use "horizontal" or "vertical".'
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
def drag(self,loc:tuple[int,int]):
|
|
375
|
+
x,y=loc
|
|
376
|
+
pg.sleep(0.5)
|
|
377
|
+
pg.dragTo(x,y,duration=0.6)
|
|
378
|
+
|
|
379
|
+
def move(self,loc:tuple[int,int]):
|
|
380
|
+
x,y=loc
|
|
381
|
+
pg.moveTo(x,y,duration=0.1)
|
|
382
|
+
|
|
383
|
+
def shortcut(self,shortcut:str):
|
|
384
|
+
shortcut=shortcut.split('+')
|
|
385
|
+
if len(shortcut)>1:
|
|
386
|
+
pg.hotkey(*shortcut)
|
|
387
|
+
else:
|
|
388
|
+
pg.press(''.join(shortcut))
|
|
389
|
+
|
|
390
|
+
def multi_select(self,press_ctrl:Literal['true','false']='false',elements:list[tuple[int,int]|int]=[]):
|
|
391
|
+
if press_ctrl=='true':
|
|
392
|
+
pg.keyDown('ctrl')
|
|
393
|
+
for element in elements:
|
|
394
|
+
x,y=element
|
|
395
|
+
pg.click(x,y,duration=0.2)
|
|
396
|
+
pg.sleep(0.5)
|
|
397
|
+
pg.keyUp('ctrl')
|
|
398
|
+
|
|
399
|
+
def multi_edit(self,elements:list[tuple[int,int,str]|tuple[int,str]]):
|
|
400
|
+
for element in elements:
|
|
401
|
+
x,y,text=element
|
|
402
|
+
self.type((x,y),text=text,clear='true')
|
|
403
|
+
|
|
404
|
+
def scrape(self,url:str)->str:
|
|
405
|
+
response=requests.get(url,timeout=10)
|
|
406
|
+
html=response.text
|
|
407
|
+
content=markdownify(html=html)
|
|
408
|
+
return content
|
|
409
|
+
|
|
410
|
+
def get_app_from_element(self,element:uia.Control)->App|None:
|
|
411
|
+
if element is None:
|
|
412
|
+
return None
|
|
413
|
+
top_window=element.GetTopLevelControl()
|
|
414
|
+
if top_window is None:
|
|
415
|
+
return None
|
|
416
|
+
handle=top_window.NativeWindowHandle
|
|
417
|
+
apps,_=self.get_apps()
|
|
418
|
+
for app in apps:
|
|
419
|
+
if app.handle==handle:
|
|
420
|
+
return app
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
def is_app_visible(self,app:uia.Control)->bool:
|
|
424
|
+
is_minimized=self.get_app_status(app)!=Status.MINIMIZED
|
|
425
|
+
size=app.BoundingRectangle
|
|
426
|
+
area=size.width()*size.height()
|
|
427
|
+
is_overlay=self.is_overlay_app(app)
|
|
428
|
+
return not is_overlay and is_minimized and area>10
|
|
429
|
+
|
|
430
|
+
def is_overlay_app(self,element:uia.Control) -> bool:
|
|
431
|
+
no_children = len(element.GetChildren()) == 0
|
|
432
|
+
is_name = "Overlay" in element.Name.strip()
|
|
433
|
+
return no_children or is_name
|
|
434
|
+
|
|
435
|
+
def get_controls_handles(self):
|
|
436
|
+
handles = set()
|
|
437
|
+
root=uia.GetRootControl()
|
|
438
|
+
children=root.GetChildren()
|
|
439
|
+
for child in children:
|
|
440
|
+
handles.add(child.NativeWindowHandle)
|
|
441
|
+
return handles
|
|
442
|
+
|
|
443
|
+
def get_active_app(self,apps:list[App]|None=None)->App|None:
|
|
444
|
+
try:
|
|
445
|
+
if apps is None:
|
|
446
|
+
apps,_=self.get_apps()
|
|
447
|
+
handle=uia.GetForegroundWindow()
|
|
448
|
+
for app in apps:
|
|
449
|
+
if app.handle!=handle:
|
|
450
|
+
continue
|
|
451
|
+
return app
|
|
452
|
+
except Exception as ex:
|
|
453
|
+
logger.error(f"Error in get_active_app: {ex}")
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
def get_apps(self,controls_handles:set[int]|None=None) -> tuple[list[App],set[int]]:
|
|
457
|
+
try:
|
|
458
|
+
apps = []
|
|
459
|
+
handles = set()
|
|
460
|
+
controls_handles=controls_handles or self.get_controls_handles()
|
|
461
|
+
for depth, hwnd in enumerate(controls_handles):
|
|
462
|
+
try:
|
|
463
|
+
child = uia.ControlFromHandle(hwnd)
|
|
464
|
+
except Exception:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
# Filter out Overlays (e.g. NVIDIA, Steam)
|
|
468
|
+
if self.is_overlay_app(child):
|
|
469
|
+
continue
|
|
470
|
+
|
|
471
|
+
if isinstance(child,(uia.WindowControl,uia.PaneControl)):
|
|
472
|
+
window_pattern=child.GetPattern(uia.PatternId.WindowPattern)
|
|
473
|
+
if (window_pattern is None):
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
if window_pattern.CanMinimize and window_pattern.CanMaximize:
|
|
477
|
+
status = self.get_app_status(child)
|
|
478
|
+
|
|
479
|
+
bounding_rect=child.BoundingRectangle
|
|
480
|
+
if bounding_rect.isempty() and status!=Status.MINIMIZED:
|
|
481
|
+
continue
|
|
482
|
+
|
|
483
|
+
apps.append(App(**{
|
|
484
|
+
"name":child.Name,
|
|
485
|
+
"runtime_id":tuple(child.GetRuntimeId()),
|
|
486
|
+
"depth":depth,
|
|
487
|
+
"status":status,
|
|
488
|
+
"bounding_box":BoundingBox(
|
|
489
|
+
left=bounding_rect.left,
|
|
490
|
+
top=bounding_rect.top,
|
|
491
|
+
right=bounding_rect.right,
|
|
492
|
+
bottom=bounding_rect.bottom,
|
|
493
|
+
width=bounding_rect.width(),
|
|
494
|
+
height=bounding_rect.height()
|
|
495
|
+
),
|
|
496
|
+
"handle":child.NativeWindowHandle,
|
|
497
|
+
"process_id":child.ProcessId,
|
|
498
|
+
"is_browser":self.is_app_browser(child)
|
|
499
|
+
}))
|
|
500
|
+
handles.add(child.NativeWindowHandle)
|
|
501
|
+
except Exception as ex:
|
|
502
|
+
logger.error(f"Error in get_apps: {ex}")
|
|
503
|
+
apps = []
|
|
504
|
+
return apps,handles
|
|
505
|
+
|
|
506
|
+
def get_xpath_from_element(self,element:uia.Control):
|
|
507
|
+
current=element
|
|
508
|
+
if current is None:
|
|
509
|
+
return ""
|
|
510
|
+
path_parts=[]
|
|
511
|
+
while current is not None:
|
|
512
|
+
parent=current.GetParentControl()
|
|
513
|
+
if parent is None:
|
|
514
|
+
# we are at the root node
|
|
515
|
+
path_parts.append(f'{current.ControlTypeName}')
|
|
516
|
+
break
|
|
517
|
+
children=parent.GetChildren()
|
|
518
|
+
same_type_children=["-".join(map(lambda x:str(x),child.GetRuntimeId())) for child in children if child.ControlType==current.ControlType]
|
|
519
|
+
index=same_type_children.index("-".join(map(lambda x:str(x),current.GetRuntimeId())))
|
|
520
|
+
if same_type_children:
|
|
521
|
+
path_parts.append(f'{current.ControlTypeName}[{index+1}]')
|
|
522
|
+
else:
|
|
523
|
+
path_parts.append(f'{current.ControlTypeName}')
|
|
524
|
+
current=parent
|
|
525
|
+
path_parts.reverse()
|
|
526
|
+
xpath="/".join(path_parts)
|
|
527
|
+
return xpath
|
|
528
|
+
|
|
529
|
+
def get_element_from_xpath(self,xpath:str)->uia.Control:
|
|
530
|
+
pattern = re.compile(r'(\w+)(?:\[(\d+)\])?')
|
|
531
|
+
parts=xpath.split("/")
|
|
532
|
+
root=uia.GetRootControl()
|
|
533
|
+
element=root
|
|
534
|
+
for part in parts[1:]:
|
|
535
|
+
match=pattern.fullmatch(part)
|
|
536
|
+
if match is None:
|
|
537
|
+
continue
|
|
538
|
+
control_type, index=match.groups()
|
|
539
|
+
index=int(index) if index else None
|
|
540
|
+
children=element.GetChildren()
|
|
541
|
+
same_type_children=list(filter(lambda x:x.ControlTypeName==control_type,children))
|
|
542
|
+
if index:
|
|
543
|
+
element=same_type_children[index-1]
|
|
544
|
+
else:
|
|
545
|
+
element=same_type_children[0]
|
|
546
|
+
return element
|
|
547
|
+
|
|
548
|
+
def get_windows_version(self)->str:
|
|
549
|
+
response,status=self.execute_command("(Get-CimInstance Win32_OperatingSystem).Caption")
|
|
550
|
+
if status==0:
|
|
551
|
+
return response.strip()
|
|
552
|
+
return "Windows"
|
|
553
|
+
|
|
554
|
+
def get_user_account_type(self)->str:
|
|
555
|
+
response,status=self.execute_command("(Get-LocalUser -Name $env:USERNAME).PrincipalSource")
|
|
556
|
+
return "Local Account" if response.strip()=='Local' else "Microsoft Account" if status==0 else "Local Account"
|
|
557
|
+
|
|
558
|
+
def get_dpi_scaling(self):
|
|
559
|
+
user32 = ctypes.windll.user32
|
|
560
|
+
dpi = user32.GetDpiForSystem()
|
|
561
|
+
return dpi / 96.0
|
|
562
|
+
|
|
563
|
+
def get_screen_size(self)->Size:
|
|
564
|
+
width, height = uia.GetVirtualScreenSize()
|
|
565
|
+
return Size(width=width,height=height)
|
|
566
|
+
|
|
567
|
+
def get_screenshot(self)->Image.Image:
|
|
568
|
+
try:
|
|
569
|
+
return ImageGrab.grab(all_screens=True)
|
|
570
|
+
except Exception as e:
|
|
571
|
+
logger.warning(f"Failed to capture all screens: {e}. Fallback to primary.")
|
|
572
|
+
return pg.screenshot()
|
|
573
|
+
|
|
574
|
+
def get_annotated_screenshot(self, nodes: list[TreeElementNode]) -> Image.Image:
|
|
575
|
+
screenshot = self.get_screenshot()
|
|
576
|
+
sleep(0.10)
|
|
577
|
+
# Add padding
|
|
578
|
+
padding = 5
|
|
579
|
+
width = int(screenshot.width + (1.5 * padding))
|
|
580
|
+
height = int(screenshot.height + (1.5 * padding))
|
|
581
|
+
padded_screenshot = Image.new("RGB", (width, height), color=(255, 255, 255))
|
|
582
|
+
padded_screenshot.paste(screenshot, (padding, padding))
|
|
583
|
+
|
|
584
|
+
draw = ImageDraw.Draw(padded_screenshot)
|
|
585
|
+
font_size = 12
|
|
586
|
+
try:
|
|
587
|
+
font = ImageFont.truetype('arial.ttf', font_size)
|
|
588
|
+
except IOError:
|
|
589
|
+
font = ImageFont.load_default()
|
|
590
|
+
|
|
591
|
+
def get_random_color():
|
|
592
|
+
return "#{:06x}".format(random.randint(0, 0xFFFFFF))
|
|
593
|
+
|
|
594
|
+
left_offset, top_offset, _, _ = uia.GetVirtualScreenRect()
|
|
595
|
+
|
|
596
|
+
def draw_annotation(label, node: TreeElementNode):
|
|
597
|
+
box = node.bounding_box
|
|
598
|
+
color = get_random_color()
|
|
599
|
+
|
|
600
|
+
# Scale and pad the bounding box also clip the bounding box
|
|
601
|
+
# Adjust for virtual screen offset so coordinates map to the screenshot image
|
|
602
|
+
adjusted_box = (
|
|
603
|
+
int(box.left - left_offset) + padding,
|
|
604
|
+
int(box.top - top_offset) + padding,
|
|
605
|
+
int(box.right - left_offset) + padding,
|
|
606
|
+
int(box.bottom - top_offset) + padding
|
|
607
|
+
)
|
|
608
|
+
# Draw bounding box
|
|
609
|
+
draw.rectangle(adjusted_box, outline=color, width=2)
|
|
610
|
+
|
|
611
|
+
# Label dimensions
|
|
612
|
+
label_width = draw.textlength(str(label), font=font)
|
|
613
|
+
label_height = font_size
|
|
614
|
+
left, top, right, bottom = adjusted_box
|
|
615
|
+
|
|
616
|
+
# Label position above bounding box
|
|
617
|
+
label_x1 = right - label_width
|
|
618
|
+
label_y1 = top - label_height - 4
|
|
619
|
+
label_x2 = label_x1 + label_width
|
|
620
|
+
label_y2 = label_y1 + label_height + 4
|
|
621
|
+
|
|
622
|
+
# Draw label background and text
|
|
623
|
+
draw.rectangle([(label_x1, label_y1), (label_x2, label_y2)], fill=color)
|
|
624
|
+
draw.text((label_x1 + 2, label_y1 + 2), str(label), fill=(255, 255, 255), font=font)
|
|
625
|
+
|
|
626
|
+
# Draw annotations in parallel
|
|
627
|
+
with ThreadPoolExecutor() as executor:
|
|
628
|
+
executor.map(draw_annotation, range(len(nodes)), nodes)
|
|
629
|
+
return padded_screenshot
|
|
630
|
+
|
|
631
|
+
@contextmanager
|
|
632
|
+
def auto_minimize(self):
|
|
633
|
+
try:
|
|
634
|
+
handle = uia.GetForegroundWindow()
|
|
635
|
+
uia.ShowWindow(handle, win32con.SW_MINIMIZE)
|
|
636
|
+
yield
|
|
637
|
+
finally:
|
|
638
|
+
uia.ShowWindow(handle, win32con.SW_RESTORE)
|