windows-mcp 0.5.7__py3-none-any.whl → 0.5.8__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.
@@ -1,458 +1,458 @@
1
- from windows_mcp.desktop.config import BROWSER_NAMES, PROCESS_PER_MONITOR_DPI_AWARE
2
- from windows_mcp.desktop.views import DesktopState, App, Size, Status
3
- from windows_mcp.tree.service import Tree
4
- from locale import getpreferredencoding
5
- from contextlib import contextmanager
6
- from typing import Optional,Literal
7
- from markdownify import markdownify
8
- from fuzzywuzzy import process
9
- from psutil import Process
10
- from time import sleep
11
- from PIL import Image
12
- import win32process
13
- import subprocess
14
- import win32gui
15
- import win32con
16
- import requests
17
- import logging
18
- import base64
19
- import ctypes
20
- import csv
21
- import re
22
- import os
23
- import io
24
-
25
- logger = logging.getLogger(__name__)
26
- logger.setLevel(logging.INFO)
27
- handler = logging.StreamHandler()
28
- formatter = logging.Formatter('[%(levelname)s] %(message)s')
29
- handler.setFormatter(formatter)
30
- logger.addHandler(handler)
31
-
32
- try:
33
- ctypes.windll.shcore.SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE)
34
- except Exception:
35
- ctypes.windll.user32.SetProcessDPIAware()
36
-
37
- import uiautomation as uia
38
- import pyautogui as pg
39
-
40
- pg.FAILSAFE=False
41
- pg.PAUSE=1.0
42
-
43
- class Desktop:
44
- def __init__(self):
45
- self.encoding=getpreferredencoding()
46
- self.tree=Tree(self)
47
- self.desktop_state=None
48
-
49
- def get_resolution(self)->tuple[int,int]:
50
- return pg.size()
51
-
52
- def get_state(self,use_vision:bool=False,use_dom:bool=False,as_bytes:bool=False,scale:float=1.0)->DesktopState:
53
- sleep(0.1)
54
- apps=self.get_apps()
55
- active_app=self.get_active_app()
56
- if active_app is not None and active_app in apps:
57
- apps.remove(active_app)
58
- logger.debug(f"Active app: {active_app}")
59
- logger.debug(f"Apps: {apps}")
60
- tree_state=self.tree.get_state(active_app,apps,use_dom=use_dom)
61
- if use_vision:
62
- screenshot=self.tree.get_annotated_screenshot(tree_state.interactive_nodes,scale=scale)
63
- if as_bytes:
64
- bytes_io=io.BytesIO()
65
- screenshot.save(bytes_io,format='PNG')
66
- screenshot=bytes_io.getvalue()
67
- else:
68
- screenshot=None
69
- self.desktop_state=DesktopState(apps= apps,active_app=active_app,screenshot=screenshot,tree_state=tree_state)
70
- return self.desktop_state
71
-
72
- def get_window_element_from_element(self,element:uia.Control)->uia.Control|None:
73
- while element is not None:
74
- if uia.IsTopLevelWindow(element.NativeWindowHandle):
75
- return element
76
- element = element.GetParentControl()
77
- return None
78
-
79
- def get_active_app(self)->App|None:
80
- try:
81
- handle=uia.GetForegroundWindow()
82
- for app in self.get_apps():
83
- if app.handle!=handle:
84
- continue
85
- return app
86
- except Exception as ex:
87
- logger.error(f"Error in get_active_app: {ex}")
88
- return None
89
-
90
- def get_app_status(self,control:uia.Control)->Status:
91
- if uia.IsIconic(control.NativeWindowHandle):
92
- return Status.MINIMIZED
93
- elif uia.IsZoomed(control.NativeWindowHandle):
94
- return Status.MAXIMIZED
95
- elif uia.IsWindowVisible(control.NativeWindowHandle):
96
- return Status.NORMAL
97
- else:
98
- return Status.HIDDEN
99
-
100
- def get_cursor_location(self)->tuple[int,int]:
101
- position=pg.position()
102
- return (position.x,position.y)
103
-
104
- def get_element_under_cursor(self)->uia.Control:
105
- return uia.ControlFromCursor()
106
-
107
- def get_apps_from_start_menu(self)->dict[str,str]:
108
- command='Get-StartApps | ConvertTo-Csv -NoTypeInformation'
109
- apps_info,_=self.execute_command(command)
110
- reader=csv.DictReader(io.StringIO(apps_info))
111
- return {row.get('Name').lower():row.get('AppID') for row in reader}
112
-
113
- def execute_command(self,command:str)->tuple[str,int]:
114
- try:
115
- encoded = base64.b64encode(command.encode("utf-16le")).decode("ascii")
116
- result = subprocess.run(
117
- ['powershell', '-NoProfile', '-EncodedCommand', encoded],
118
- capture_output=True,
119
- errors='ignore',
120
- timeout=25,
121
- cwd=os.path.expanduser(path='~')
122
- )
123
- stdout=result.stdout
124
- stderr=result.stderr
125
- return (stdout or stderr,result.returncode)
126
- except subprocess.TimeoutExpired:
127
- return ('Command execution timed out', 1)
128
- except Exception as e:
129
- return ('Command execution failed', 1)
130
-
131
- def is_app_browser(self,node:uia.Control):
132
- process=Process(node.ProcessId)
133
- return process.name() in BROWSER_NAMES
134
-
135
- def get_default_language(self)->str:
136
- command="Get-Culture | Select-Object Name,DisplayName | ConvertTo-Csv -NoTypeInformation"
137
- response,_=self.execute_command(command)
138
- reader=csv.DictReader(io.StringIO(response))
139
- return "".join([row.get('DisplayName') for row in reader])
140
-
141
- def resize_app(self,size:tuple[int,int]=None,loc:tuple[int,int]=None)->tuple[str,int]:
142
- active_app=self.desktop_state.active_app
143
- if active_app is None:
144
- return "No active app found",1
145
- if active_app.status==Status.MINIMIZED:
146
- return f"{active_app.name} is minimized",1
147
- elif active_app.status==Status.MAXIMIZED:
148
- return f"{active_app.name} is maximized",1
149
- else:
150
- app_control=uia.ControlFromHandle(active_app.handle)
151
- if loc is None:
152
- x=app_control.BoundingRectangle.left
153
- y=app_control.BoundingRectangle.top
154
- loc=(x,y)
155
- if size is None:
156
- width=app_control.BoundingRectangle.width()
157
- height=app_control.BoundingRectangle.height()
158
- size=(width,height)
159
- x,y=loc
160
- width,height=size
161
- app_control.MoveWindow(x,y,width,height)
162
- return (f'{active_app.name} resized to {width}x{height} at {x},{y}.',0)
163
-
164
- def is_app_running(self,name:str)->bool:
165
- apps={app.name:app for app in self.get_apps()}
166
- return process.extractOne(name,list(apps.keys()),score_cutoff=60) is not None
167
-
168
- def app(self,mode:Literal['launch','switch','resize'],name:Optional[str]=None,loc:Optional[tuple[int,int]]=None,size:Optional[tuple[int,int]]=None):
169
- match mode:
170
- case 'launch':
171
- response,status=self.launch_app(name)
172
- sleep(1.25)
173
- if status!=0:
174
- return response
175
- consecutive_waits=10
176
- for _ in range(consecutive_waits):
177
- if not self.is_app_running(name):
178
- sleep(1.25)
179
- else:
180
- return f'{name.title()} launched.'
181
- return f'Launching {name.title()} wait for it to come load.'
182
- case 'resize':
183
- response,status=self.resize_app(size=size,loc=loc)
184
- if status!=0:
185
- return response
186
- else:
187
- return response
188
- case 'switch':
189
- response,status=self.switch_app(name)
190
- if status!=0:
191
- return response
192
- else:
193
- return response
194
-
195
- def launch_app(self,name:str)->tuple[str,int]:
196
- apps_map=self.get_apps_from_start_menu()
197
- matched_app=process.extractOne(name,apps_map.keys(),score_cutoff=70)
198
- if matched_app is None:
199
- return (f'{name.title()} not found in start menu.',1)
200
- app_name,_=matched_app
201
- appid=apps_map.get(app_name)
202
- if appid is None:
203
- return (f'{name.title()} not found in start menu.',1)
204
- if appid.endswith('.exe'):
205
- command=f"Start-Process '{appid}'"
206
- else:
207
- command=f"Start-Process shell:AppsFolder\\{appid}"
208
- response,status=self.execute_command(command)
209
- return response,status
210
-
211
- def switch_app(self,name:str):
212
- apps={app.name:app for app in [self.desktop_state.active_app]+self.desktop_state.apps if app is not None}
213
- matched_app:Optional[tuple[str,float]]=process.extractOne(name,list(apps.keys()),score_cutoff=70)
214
- if matched_app is None:
215
- return (f'Application {name.title()} not found.',1)
216
- app_name,_=matched_app
217
- app=apps.get(app_name)
218
- target_handle=app.handle
219
-
220
- if uia.IsIconic(target_handle):
221
- uia.ShowWindow(target_handle, win32con.SW_RESTORE)
222
- content=f'{app_name.title()} restored from Minimized state.'
223
- else:
224
- self.bring_window_to_top(target_handle)
225
- content=f'Switched to {app_name.title()} window.'
226
- return content,0
227
-
228
- def bring_window_to_top(self,target_handle:int):
229
- foreground_handle=win32gui.GetForegroundWindow()
230
- foreground_thread,_=win32process.GetWindowThreadProcessId(foreground_handle)
231
- target_thread,_=win32process.GetWindowThreadProcessId(target_handle)
232
- try:
233
- ctypes.windll.user32.AllowSetForegroundWindow(-1)
234
- win32process.AttachThreadInput(foreground_thread,target_thread,True)
235
- win32gui.SetForegroundWindow(target_handle)
236
- win32gui.BringWindowToTop(target_handle)
237
- except Exception as e:
238
- logger.error(f'Failed to bring window to top: {e}')
239
- finally:
240
- win32process.AttachThreadInput(foreground_thread,target_thread,False)
241
-
242
- def get_element_handle_from_label(self,label:int)->uia.Control:
243
- tree_state=self.desktop_state.tree_state
244
- element_node=tree_state.interactive_nodes[label]
245
- xpath=element_node.xpath
246
- element_handle=self.get_element_from_xpath(xpath)
247
- return element_handle
248
-
249
- def get_coordinates_from_label(self,label:int)->tuple[int,int]:
250
- element_handle=self.get_element_handle_from_label(label)
251
- bounding_rectangle=element_handle.BoundingRectangle
252
- return bounding_rectangle.xcenter(),bounding_rectangle.ycenter()
253
-
254
- def click(self,loc:tuple[int,int],button:str='left',clicks:int=2):
255
- x,y=loc
256
- pg.click(x,y,button=button,clicks=clicks,duration=0.1)
257
-
258
- 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'):
259
- x,y=loc
260
- pg.leftClick(x,y)
261
- if caret_position == 'start':
262
- pg.press('home')
263
- elif caret_position == 'end':
264
- pg.press('end')
265
- else:
266
- pass
267
- if clear=='true':
268
- pg.sleep(0.5)
269
- pg.hotkey('ctrl','a')
270
- pg.press('backspace')
271
- pg.typewrite(text,interval=0.02)
272
- if press_enter=='true':
273
- pg.press('enter')
274
-
275
- 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:
276
- if loc:
277
- self.move(loc)
278
- match type:
279
- case 'vertical':
280
- match direction:
281
- case 'up':
282
- uia.WheelUp(wheel_times)
283
- case 'down':
284
- uia.WheelDown(wheel_times)
285
- case _:
286
- return 'Invalid direction. Use "up" or "down".'
287
- case 'horizontal':
288
- match direction:
289
- case 'left':
290
- pg.keyDown('Shift')
291
- pg.sleep(0.05)
292
- uia.WheelUp(wheel_times)
293
- pg.sleep(0.05)
294
- pg.keyUp('Shift')
295
- case 'right':
296
- pg.keyDown('Shift')
297
- pg.sleep(0.05)
298
- uia.WheelDown(wheel_times)
299
- pg.sleep(0.05)
300
- pg.keyUp('Shift')
301
- case _:
302
- return 'Invalid direction. Use "left" or "right".'
303
- case _:
304
- return 'Invalid type. Use "horizontal" or "vertical".'
305
- return None
306
-
307
- def drag(self,loc:tuple[int,int]):
308
- x,y=loc
309
- pg.sleep(0.5)
310
- pg.dragTo(x,y,duration=0.6)
311
-
312
- def move(self,loc:tuple[int,int]):
313
- x,y=loc
314
- pg.moveTo(x,y,duration=0.1)
315
-
316
- def shortcut(self,shortcut:str):
317
- shortcut=shortcut.split('+')
318
- if len(shortcut)>1:
319
- pg.hotkey(*shortcut)
320
- else:
321
- pg.press(''.join(shortcut))
322
-
323
- def multi_select(self,press_ctrl:Literal['true','false']='false',elements:list[tuple[int,int]|int]=[]):
324
- if press_ctrl=='true':
325
- pg.keyDown('ctrl')
326
- for element in elements:
327
- x,y=element
328
- pg.click(x,y,duration=0.2)
329
- pg.sleep(0.5)
330
- pg.keyUp('ctrl')
331
-
332
- def multi_edit(self,elements:list[tuple[int,int,str]|tuple[int,str]]):
333
- for element in elements:
334
- x,y,text=element
335
- self.type((x,y),text=text,clear='true')
336
-
337
- def scrape(self,url:str)->str:
338
- response=requests.get(url,timeout=10)
339
- html=response.text
340
- content=markdownify(html=html)
341
- return content
342
-
343
- def get_app_size(self,control:uia.Control):
344
- window=control.BoundingRectangle
345
- if window.isempty():
346
- return Size(width=0,height=0)
347
- return Size(width=window.width(),height=window.height())
348
-
349
- def is_app_visible(self,app)->bool:
350
- is_minimized=self.get_app_status(app)!=Status.MINIMIZED
351
- size=self.get_app_size(app)
352
- area=size.width*size.height
353
- is_overlay=self.is_overlay_app(app)
354
- return not is_overlay and is_minimized and area>10
355
-
356
- def is_overlay_app(self,element:uia.Control) -> bool:
357
- no_children = len(element.GetChildren()) == 0
358
- is_name = "Overlay" in element.Name.strip()
359
- return no_children or is_name
360
-
361
- def get_apps(self) -> list[App]:
362
- try:
363
- desktop = uia.GetRootControl() # Get the desktop control
364
- children = desktop.GetChildren()
365
- apps = []
366
- for depth, child in enumerate(children):
367
- if isinstance(child,(uia.WindowControl,uia.PaneControl)):
368
- window_pattern=child.GetPattern(uia.PatternId.WindowPattern)
369
- if (window_pattern is None):
370
- continue
371
- if window_pattern.CanMinimize and window_pattern.CanMaximize:
372
- status = self.get_app_status(child)
373
- size=self.get_app_size(child)
374
- apps.append(App(**{
375
- "name":child.Name,
376
- "depth":depth,
377
- "status":status,
378
- "size":size,
379
- "handle":child.NativeWindowHandle,
380
- "process_id":child.ProcessId
381
- }))
382
- except Exception as ex:
383
- logger.error(f"Error in get_apps: {ex}")
384
- apps = []
385
- return apps
386
-
387
- def get_xpath_from_element(self,element:uia.Control):
388
- current=element
389
- if current is None:
390
- return ""
391
- path_parts=[]
392
- while current is not None:
393
- parent=current.GetParentControl()
394
- if parent is None:
395
- # we are at the root node
396
- path_parts.append(f'{current.ControlTypeName}')
397
- break
398
- children=parent.GetChildren()
399
- same_type_children=["-".join(map(lambda x:str(x),child.GetRuntimeId())) for child in children if child.ControlType==current.ControlType]
400
- index=same_type_children.index("-".join(map(lambda x:str(x),current.GetRuntimeId())))
401
- if same_type_children:
402
- path_parts.append(f'{current.ControlTypeName}[{index+1}]')
403
- else:
404
- path_parts.append(f'{current.ControlTypeName}')
405
- current=parent
406
- path_parts.reverse()
407
- xpath="/".join(path_parts)
408
- return xpath
409
-
410
- def get_element_from_xpath(self,xpath:str)->uia.Control:
411
- pattern = re.compile(r'(\w+)(?:\[(\d+)\])?')
412
- parts=xpath.split("/")
413
- root=uia.GetRootControl()
414
- element=root
415
- for part in parts[1:]:
416
- match=pattern.fullmatch(part)
417
- if match is None:
418
- continue
419
- control_type, index=match.groups()
420
- index=int(index) if index else None
421
- children=element.GetChildren()
422
- same_type_children=list(filter(lambda x:x.ControlTypeName==control_type,children))
423
- if index:
424
- element=same_type_children[index-1]
425
- else:
426
- element=same_type_children[0]
427
- return element
428
-
429
- def get_windows_version(self)->str:
430
- response,status=self.execute_command("(Get-CimInstance Win32_OperatingSystem).Caption")
431
- if status==0:
432
- return response.strip()
433
- return "Windows"
434
-
435
- def get_user_account_type(self)->str:
436
- response,status=self.execute_command("(Get-LocalUser -Name $env:USERNAME).PrincipalSource")
437
- return "Local Account" if response.strip()=='Local' else "Microsoft Account" if status==0 else "Local Account"
438
-
439
- def get_dpi_scaling(self):
440
- user32 = ctypes.windll.user32
441
- dpi = user32.GetDpiForSystem()
442
- return dpi / 96.0
443
-
444
- def get_screen_size(self)->Size:
445
- width, height = uia.GetScreenSize()
446
- return Size(width=width,height=height)
447
-
448
- def get_screenshot(self)->Image.Image:
449
- return pg.screenshot()
450
-
451
- @contextmanager
452
- def auto_minimize(self):
453
- try:
454
- handle = uia.GetForegroundWindow()
455
- uia.ShowWindow(handle, win32con.SW_MINIMIZE)
456
- yield
457
- finally:
1
+ from windows_mcp.desktop.config import BROWSER_NAMES, PROCESS_PER_MONITOR_DPI_AWARE
2
+ from windows_mcp.desktop.views import DesktopState, App, Size, Status
3
+ from windows_mcp.tree.service import Tree
4
+ from locale import getpreferredencoding
5
+ from contextlib import contextmanager
6
+ from typing import Optional,Literal
7
+ from markdownify import markdownify
8
+ from fuzzywuzzy import process
9
+ from psutil import Process
10
+ from time import sleep
11
+ from PIL import Image
12
+ import win32process
13
+ import subprocess
14
+ import win32gui
15
+ import win32con
16
+ import requests
17
+ import logging
18
+ import base64
19
+ import ctypes
20
+ import csv
21
+ import re
22
+ import os
23
+ import io
24
+
25
+ logger = logging.getLogger(__name__)
26
+ logger.setLevel(logging.INFO)
27
+ handler = logging.StreamHandler()
28
+ formatter = logging.Formatter('[%(levelname)s] %(message)s')
29
+ handler.setFormatter(formatter)
30
+ logger.addHandler(handler)
31
+
32
+ try:
33
+ ctypes.windll.shcore.SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE)
34
+ except Exception:
35
+ ctypes.windll.user32.SetProcessDPIAware()
36
+
37
+ import windows_mcp.uia as uia
38
+ import pyautogui as pg
39
+
40
+ pg.FAILSAFE=False
41
+ pg.PAUSE=1.0
42
+
43
+ class Desktop:
44
+ def __init__(self):
45
+ self.encoding=getpreferredencoding()
46
+ self.tree=Tree(self)
47
+ self.desktop_state=None
48
+
49
+ def get_resolution(self)->tuple[int,int]:
50
+ return pg.size()
51
+
52
+ def get_state(self,use_vision:bool=False,use_dom:bool=False,as_bytes:bool=False,scale:float=1.0)->DesktopState:
53
+ sleep(0.1)
54
+ apps=self.get_apps()
55
+ active_app=self.get_active_app()
56
+ if active_app is not None and active_app in apps:
57
+ apps.remove(active_app)
58
+ logger.debug(f"Active app: {active_app}")
59
+ logger.debug(f"Apps: {apps}")
60
+ tree_state=self.tree.get_state(active_app,apps,use_dom=use_dom)
61
+ if use_vision:
62
+ screenshot=self.tree.get_annotated_screenshot(tree_state.interactive_nodes,scale=scale)
63
+ if as_bytes:
64
+ bytes_io=io.BytesIO()
65
+ screenshot.save(bytes_io,format='PNG')
66
+ screenshot=bytes_io.getvalue()
67
+ else:
68
+ screenshot=None
69
+ self.desktop_state=DesktopState(apps= apps,active_app=active_app,screenshot=screenshot,tree_state=tree_state)
70
+ return self.desktop_state
71
+
72
+ def get_window_element_from_element(self,element:uia.Control)->uia.Control|None:
73
+ while element is not None:
74
+ if uia.IsTopLevelWindow(element.NativeWindowHandle):
75
+ return element
76
+ element = element.GetParentControl()
77
+ return None
78
+
79
+ def get_active_app(self)->App|None:
80
+ try:
81
+ handle=uia.GetForegroundWindow()
82
+ for app in self.get_apps():
83
+ if app.handle!=handle:
84
+ continue
85
+ return app
86
+ except Exception as ex:
87
+ logger.error(f"Error in get_active_app: {ex}")
88
+ return None
89
+
90
+ def get_app_status(self,control:uia.Control)->Status:
91
+ if uia.IsIconic(control.NativeWindowHandle):
92
+ return Status.MINIMIZED
93
+ elif uia.IsZoomed(control.NativeWindowHandle):
94
+ return Status.MAXIMIZED
95
+ elif uia.IsWindowVisible(control.NativeWindowHandle):
96
+ return Status.NORMAL
97
+ else:
98
+ return Status.HIDDEN
99
+
100
+ def get_cursor_location(self)->tuple[int,int]:
101
+ position=pg.position()
102
+ return (position.x,position.y)
103
+
104
+ def get_element_under_cursor(self)->uia.Control:
105
+ return uia.ControlFromCursor()
106
+
107
+ def get_apps_from_start_menu(self)->dict[str,str]:
108
+ command='Get-StartApps | ConvertTo-Csv -NoTypeInformation'
109
+ apps_info,_=self.execute_command(command)
110
+ reader=csv.DictReader(io.StringIO(apps_info))
111
+ return {row.get('Name').lower():row.get('AppID') for row in reader}
112
+
113
+ def execute_command(self,command:str)->tuple[str,int]:
114
+ try:
115
+ encoded = base64.b64encode(command.encode("utf-16le")).decode("ascii")
116
+ result = subprocess.run(
117
+ ['powershell', '-NoProfile', '-EncodedCommand', encoded],
118
+ capture_output=True,
119
+ errors='ignore',
120
+ timeout=25,
121
+ cwd=os.path.expanduser(path='~')
122
+ )
123
+ stdout=result.stdout
124
+ stderr=result.stderr
125
+ return (stdout or stderr,result.returncode)
126
+ except subprocess.TimeoutExpired:
127
+ return ('Command execution timed out', 1)
128
+ except Exception as e:
129
+ return ('Command execution failed', 1)
130
+
131
+ def is_app_browser(self,node:uia.Control):
132
+ process=Process(node.ProcessId)
133
+ return process.name() in BROWSER_NAMES
134
+
135
+ def get_default_language(self)->str:
136
+ command="Get-Culture | Select-Object Name,DisplayName | ConvertTo-Csv -NoTypeInformation"
137
+ response,_=self.execute_command(command)
138
+ reader=csv.DictReader(io.StringIO(response))
139
+ return "".join([row.get('DisplayName') for row in reader])
140
+
141
+ def resize_app(self,size:tuple[int,int]=None,loc:tuple[int,int]=None)->tuple[str,int]:
142
+ active_app=self.desktop_state.active_app
143
+ if active_app is None:
144
+ return "No active app found",1
145
+ if active_app.status==Status.MINIMIZED:
146
+ return f"{active_app.name} is minimized",1
147
+ elif active_app.status==Status.MAXIMIZED:
148
+ return f"{active_app.name} is maximized",1
149
+ else:
150
+ app_control=uia.ControlFromHandle(active_app.handle)
151
+ if loc is None:
152
+ x=app_control.BoundingRectangle.left
153
+ y=app_control.BoundingRectangle.top
154
+ loc=(x,y)
155
+ if size is None:
156
+ width=app_control.BoundingRectangle.width()
157
+ height=app_control.BoundingRectangle.height()
158
+ size=(width,height)
159
+ x,y=loc
160
+ width,height=size
161
+ app_control.MoveWindow(x,y,width,height)
162
+ return (f'{active_app.name} resized to {width}x{height} at {x},{y}.',0)
163
+
164
+ def is_app_running(self,name:str)->bool:
165
+ apps={app.name:app for app in self.get_apps()}
166
+ return process.extractOne(name,list(apps.keys()),score_cutoff=60) is not None
167
+
168
+ def app(self,mode:Literal['launch','switch','resize'],name:Optional[str]=None,loc:Optional[tuple[int,int]]=None,size:Optional[tuple[int,int]]=None):
169
+ match mode:
170
+ case 'launch':
171
+ response,status=self.launch_app(name)
172
+ sleep(1.25)
173
+ if status!=0:
174
+ return response
175
+ consecutive_waits=10
176
+ for _ in range(consecutive_waits):
177
+ if not self.is_app_running(name):
178
+ sleep(1.25)
179
+ else:
180
+ return f'{name.title()} launched.'
181
+ return f'Launching {name.title()} wait for it to come load.'
182
+ case 'resize':
183
+ response,status=self.resize_app(size=size,loc=loc)
184
+ if status!=0:
185
+ return response
186
+ else:
187
+ return response
188
+ case 'switch':
189
+ response,status=self.switch_app(name)
190
+ if status!=0:
191
+ return response
192
+ else:
193
+ return response
194
+
195
+ def launch_app(self,name:str)->tuple[str,int]:
196
+ apps_map=self.get_apps_from_start_menu()
197
+ matched_app=process.extractOne(name,apps_map.keys(),score_cutoff=70)
198
+ if matched_app is None:
199
+ return (f'{name.title()} not found in start menu.',1)
200
+ app_name,_=matched_app
201
+ appid=apps_map.get(app_name)
202
+ if appid is None:
203
+ return (f'{name.title()} not found in start menu.',1)
204
+ if appid.endswith('.exe'):
205
+ command=f"Start-Process '{appid}'"
206
+ else:
207
+ command=f"Start-Process shell:AppsFolder\\{appid}"
208
+ response,status=self.execute_command(command)
209
+ return response,status
210
+
211
+ def switch_app(self,name:str):
212
+ apps={app.name:app for app in [self.desktop_state.active_app]+self.desktop_state.apps if app is not None}
213
+ matched_app:Optional[tuple[str,float]]=process.extractOne(name,list(apps.keys()),score_cutoff=70)
214
+ if matched_app is None:
215
+ return (f'Application {name.title()} not found.',1)
216
+ app_name,_=matched_app
217
+ app=apps.get(app_name)
218
+ target_handle=app.handle
219
+
220
+ if uia.IsIconic(target_handle):
221
+ uia.ShowWindow(target_handle, win32con.SW_RESTORE)
222
+ content=f'{app_name.title()} restored from Minimized state.'
223
+ else:
224
+ self.bring_window_to_top(target_handle)
225
+ content=f'Switched to {app_name.title()} window.'
226
+ return content,0
227
+
228
+ def bring_window_to_top(self,target_handle:int):
229
+ foreground_handle=win32gui.GetForegroundWindow()
230
+ foreground_thread,_=win32process.GetWindowThreadProcessId(foreground_handle)
231
+ target_thread,_=win32process.GetWindowThreadProcessId(target_handle)
232
+ try:
233
+ ctypes.windll.user32.AllowSetForegroundWindow(-1)
234
+ win32process.AttachThreadInput(foreground_thread,target_thread,True)
235
+ win32gui.SetForegroundWindow(target_handle)
236
+ win32gui.BringWindowToTop(target_handle)
237
+ except Exception as e:
238
+ logger.error(f'Failed to bring window to top: {e}')
239
+ finally:
240
+ win32process.AttachThreadInput(foreground_thread,target_thread,False)
241
+
242
+ def get_element_handle_from_label(self,label:int)->uia.Control:
243
+ tree_state=self.desktop_state.tree_state
244
+ element_node=tree_state.interactive_nodes[label]
245
+ xpath=element_node.xpath
246
+ element_handle=self.get_element_from_xpath(xpath)
247
+ return element_handle
248
+
249
+ def get_coordinates_from_label(self,label:int)->tuple[int,int]:
250
+ element_handle=self.get_element_handle_from_label(label)
251
+ bounding_rectangle=element_handle.BoundingRectangle
252
+ return bounding_rectangle.xcenter(),bounding_rectangle.ycenter()
253
+
254
+ def click(self,loc:tuple[int,int],button:str='left',clicks:int=2):
255
+ x,y=loc
256
+ pg.click(x,y,button=button,clicks=clicks,duration=0.1)
257
+
258
+ 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'):
259
+ x,y=loc
260
+ pg.leftClick(x,y)
261
+ if caret_position == 'start':
262
+ pg.press('home')
263
+ elif caret_position == 'end':
264
+ pg.press('end')
265
+ else:
266
+ pass
267
+ if clear=='true':
268
+ pg.sleep(0.5)
269
+ pg.hotkey('ctrl','a')
270
+ pg.press('backspace')
271
+ pg.typewrite(text,interval=0.02)
272
+ if press_enter=='true':
273
+ pg.press('enter')
274
+
275
+ 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:
276
+ if loc:
277
+ self.move(loc)
278
+ match type:
279
+ case 'vertical':
280
+ match direction:
281
+ case 'up':
282
+ uia.WheelUp(wheel_times)
283
+ case 'down':
284
+ uia.WheelDown(wheel_times)
285
+ case _:
286
+ return 'Invalid direction. Use "up" or "down".'
287
+ case 'horizontal':
288
+ match direction:
289
+ case 'left':
290
+ pg.keyDown('Shift')
291
+ pg.sleep(0.05)
292
+ uia.WheelUp(wheel_times)
293
+ pg.sleep(0.05)
294
+ pg.keyUp('Shift')
295
+ case 'right':
296
+ pg.keyDown('Shift')
297
+ pg.sleep(0.05)
298
+ uia.WheelDown(wheel_times)
299
+ pg.sleep(0.05)
300
+ pg.keyUp('Shift')
301
+ case _:
302
+ return 'Invalid direction. Use "left" or "right".'
303
+ case _:
304
+ return 'Invalid type. Use "horizontal" or "vertical".'
305
+ return None
306
+
307
+ def drag(self,loc:tuple[int,int]):
308
+ x,y=loc
309
+ pg.sleep(0.5)
310
+ pg.dragTo(x,y,duration=0.6)
311
+
312
+ def move(self,loc:tuple[int,int]):
313
+ x,y=loc
314
+ pg.moveTo(x,y,duration=0.1)
315
+
316
+ def shortcut(self,shortcut:str):
317
+ shortcut=shortcut.split('+')
318
+ if len(shortcut)>1:
319
+ pg.hotkey(*shortcut)
320
+ else:
321
+ pg.press(''.join(shortcut))
322
+
323
+ def multi_select(self,press_ctrl:Literal['true','false']='false',elements:list[tuple[int,int]|int]=[]):
324
+ if press_ctrl=='true':
325
+ pg.keyDown('ctrl')
326
+ for element in elements:
327
+ x,y=element
328
+ pg.click(x,y,duration=0.2)
329
+ pg.sleep(0.5)
330
+ pg.keyUp('ctrl')
331
+
332
+ def multi_edit(self,elements:list[tuple[int,int,str]|tuple[int,str]]):
333
+ for element in elements:
334
+ x,y,text=element
335
+ self.type((x,y),text=text,clear='true')
336
+
337
+ def scrape(self,url:str)->str:
338
+ response=requests.get(url,timeout=10)
339
+ html=response.text
340
+ content=markdownify(html=html)
341
+ return content
342
+
343
+ def get_app_size(self,control:uia.Control):
344
+ window=control.BoundingRectangle
345
+ if window.isempty():
346
+ return Size(width=0,height=0)
347
+ return Size(width=window.width(),height=window.height())
348
+
349
+ def is_app_visible(self,app)->bool:
350
+ is_minimized=self.get_app_status(app)!=Status.MINIMIZED
351
+ size=self.get_app_size(app)
352
+ area=size.width*size.height
353
+ is_overlay=self.is_overlay_app(app)
354
+ return not is_overlay and is_minimized and area>10
355
+
356
+ def is_overlay_app(self,element:uia.Control) -> bool:
357
+ no_children = len(element.GetChildren()) == 0
358
+ is_name = "Overlay" in element.Name.strip()
359
+ return no_children or is_name
360
+
361
+ def get_apps(self) -> list[App]:
362
+ try:
363
+ desktop = uia.GetRootControl() # Get the desktop control
364
+ children = desktop.GetChildren()
365
+ apps = []
366
+ for depth, child in enumerate(children):
367
+ if isinstance(child,(uia.WindowControl,uia.PaneControl)):
368
+ window_pattern=child.GetPattern(uia.PatternId.WindowPattern)
369
+ if (window_pattern is None):
370
+ continue
371
+ if window_pattern.CanMinimize and window_pattern.CanMaximize:
372
+ status = self.get_app_status(child)
373
+ size=self.get_app_size(child)
374
+ apps.append(App(**{
375
+ "name":child.Name,
376
+ "depth":depth,
377
+ "status":status,
378
+ "size":size,
379
+ "handle":child.NativeWindowHandle,
380
+ "process_id":child.ProcessId
381
+ }))
382
+ except Exception as ex:
383
+ logger.error(f"Error in get_apps: {ex}")
384
+ apps = []
385
+ return apps
386
+
387
+ def get_xpath_from_element(self,element:uia.Control):
388
+ current=element
389
+ if current is None:
390
+ return ""
391
+ path_parts=[]
392
+ while current is not None:
393
+ parent=current.GetParentControl()
394
+ if parent is None:
395
+ # we are at the root node
396
+ path_parts.append(f'{current.ControlTypeName}')
397
+ break
398
+ children=parent.GetChildren()
399
+ same_type_children=["-".join(map(lambda x:str(x),child.GetRuntimeId())) for child in children if child.ControlType==current.ControlType]
400
+ index=same_type_children.index("-".join(map(lambda x:str(x),current.GetRuntimeId())))
401
+ if same_type_children:
402
+ path_parts.append(f'{current.ControlTypeName}[{index+1}]')
403
+ else:
404
+ path_parts.append(f'{current.ControlTypeName}')
405
+ current=parent
406
+ path_parts.reverse()
407
+ xpath="/".join(path_parts)
408
+ return xpath
409
+
410
+ def get_element_from_xpath(self,xpath:str)->uia.Control:
411
+ pattern = re.compile(r'(\w+)(?:\[(\d+)\])?')
412
+ parts=xpath.split("/")
413
+ root=uia.GetRootControl()
414
+ element=root
415
+ for part in parts[1:]:
416
+ match=pattern.fullmatch(part)
417
+ if match is None:
418
+ continue
419
+ control_type, index=match.groups()
420
+ index=int(index) if index else None
421
+ children=element.GetChildren()
422
+ same_type_children=list(filter(lambda x:x.ControlTypeName==control_type,children))
423
+ if index:
424
+ element=same_type_children[index-1]
425
+ else:
426
+ element=same_type_children[0]
427
+ return element
428
+
429
+ def get_windows_version(self)->str:
430
+ response,status=self.execute_command("(Get-CimInstance Win32_OperatingSystem).Caption")
431
+ if status==0:
432
+ return response.strip()
433
+ return "Windows"
434
+
435
+ def get_user_account_type(self)->str:
436
+ response,status=self.execute_command("(Get-LocalUser -Name $env:USERNAME).PrincipalSource")
437
+ return "Local Account" if response.strip()=='Local' else "Microsoft Account" if status==0 else "Local Account"
438
+
439
+ def get_dpi_scaling(self):
440
+ user32 = ctypes.windll.user32
441
+ dpi = user32.GetDpiForSystem()
442
+ return dpi / 96.0
443
+
444
+ def get_screen_size(self)->Size:
445
+ width, height = uia.GetScreenSize()
446
+ return Size(width=width,height=height)
447
+
448
+ def get_screenshot(self)->Image.Image:
449
+ return pg.screenshot()
450
+
451
+ @contextmanager
452
+ def auto_minimize(self):
453
+ try:
454
+ handle = uia.GetForegroundWindow()
455
+ uia.ShowWindow(handle, win32con.SW_MINIMIZE)
456
+ yield
457
+ finally:
458
458
  uia.ShowWindow(handle, win32con.SW_RESTORE)