windows-mcp 0.6.0__py3-none-any.whl → 0.6.2__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 +66 -18
- windows_mcp/desktop/service.py +189 -101
- windows_mcp/desktop/views.py +32 -15
- windows_mcp/tree/service.py +29 -29
- windows_mcp/tree/utils.py +21 -21
- windows_mcp/tree/views.py +9 -9
- windows_mcp/uia/controls.py +0 -42
- windows_mcp/uia/core.py +19 -929
- windows_mcp/vdm/core.py +147 -135
- {windows_mcp-0.6.0.dist-info → windows_mcp-0.6.2.dist-info}/METADATA +6 -5
- {windows_mcp-0.6.0.dist-info → windows_mcp-0.6.2.dist-info}/RECORD +14 -14
- {windows_mcp-0.6.0.dist-info → windows_mcp-0.6.2.dist-info}/WHEEL +0 -0
- {windows_mcp-0.6.0.dist-info → windows_mcp-0.6.2.dist-info}/entry_points.txt +0 -0
- {windows_mcp-0.6.0.dist-info → windows_mcp-0.6.2.dist-info}/licenses/LICENSE.md +0 -0
windows_mcp/desktop/service.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
from windows_mcp.
|
|
2
|
-
from windows_mcp.desktop.views import DesktopState,
|
|
1
|
+
from windows_mcp.vdm.core import get_all_desktops, get_current_desktop, is_window_on_current_desktop
|
|
2
|
+
from windows_mcp.desktop.views import DesktopState, Window, Browser, Status, Size
|
|
3
|
+
from windows_mcp.desktop.config import PROCESS_PER_MONITOR_DPI_AWARE
|
|
3
4
|
from windows_mcp.tree.views import BoundingBox, TreeElementNode
|
|
4
5
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
6
|
from PIL import ImageGrab, ImageFont, ImageDraw, Image
|
|
@@ -45,25 +46,36 @@ class Desktop:
|
|
|
45
46
|
self.tree=Tree(self)
|
|
46
47
|
self.desktop_state=None
|
|
47
48
|
|
|
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
|
-
|
|
49
|
+
def get_state(self,use_annotation:bool|str=True,use_vision:bool|str=False,use_dom:bool|str=False,as_bytes:bool|str=False,scale:float=1.0)->DesktopState:
|
|
50
|
+
use_annotation = use_annotation is True or (isinstance(use_annotation, str) and use_annotation.lower() == 'true')
|
|
51
|
+
use_vision = use_vision is True or (isinstance(use_vision, str) and use_vision.lower() == 'true')
|
|
52
|
+
use_dom = use_dom is True or (isinstance(use_dom, str) and use_dom.lower() == 'true')
|
|
53
|
+
as_bytes = as_bytes is True or (isinstance(as_bytes, str) and as_bytes.lower() == 'true')
|
|
54
|
+
|
|
50
55
|
start_time = time()
|
|
51
56
|
|
|
52
57
|
controls_handles=self.get_controls_handles() # Taskbar,Program Manager,Apps, Dialogs
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
58
|
+
windows,windows_handles=self.get_windows(controls_handles=controls_handles) # Apps
|
|
59
|
+
active_window=self.get_active_window(windows=windows) #Active Window
|
|
60
|
+
active_window_handle=active_window.handle if active_window else None
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
active_desktop=get_current_desktop()
|
|
64
|
+
all_desktops=get_all_desktops()
|
|
65
|
+
except RuntimeError:
|
|
66
|
+
active_desktop = {'id': '00000000-0000-0000-0000-000000000000', 'name': 'Default Desktop'}
|
|
67
|
+
all_desktops = [active_desktop]
|
|
56
68
|
|
|
57
|
-
if
|
|
58
|
-
|
|
69
|
+
if active_window is not None and active_window in windows:
|
|
70
|
+
windows.remove(active_window)
|
|
59
71
|
|
|
60
|
-
logger.debug(f"Active
|
|
61
|
-
logger.debug(f"
|
|
72
|
+
logger.debug(f"Active window: {active_window or 'No Active Window Found'}")
|
|
73
|
+
logger.debug(f"Windows: {windows}")
|
|
62
74
|
|
|
63
75
|
#Preparing handles for Tree
|
|
64
|
-
|
|
76
|
+
other_windows_handles=list(controls_handles-windows_handles)
|
|
65
77
|
|
|
66
|
-
tree_state=self.tree.get_state(
|
|
78
|
+
tree_state=self.tree.get_state(active_window_handle,other_windows_handles,use_dom=use_dom)
|
|
67
79
|
|
|
68
80
|
if use_vision:
|
|
69
81
|
if use_annotation:
|
|
@@ -83,13 +95,20 @@ class Desktop:
|
|
|
83
95
|
else:
|
|
84
96
|
screenshot=None
|
|
85
97
|
|
|
86
|
-
self.desktop_state=DesktopState(
|
|
98
|
+
self.desktop_state=DesktopState(
|
|
99
|
+
active_window=active_window,
|
|
100
|
+
windows=windows,
|
|
101
|
+
active_desktop=active_desktop,
|
|
102
|
+
all_desktops=all_desktops,
|
|
103
|
+
screenshot=screenshot,
|
|
104
|
+
tree_state=tree_state
|
|
105
|
+
)
|
|
87
106
|
# Log the time taken to capture the state
|
|
88
107
|
end_time = time()
|
|
89
108
|
logger.info(f"Desktop State capture took {end_time - start_time:.2f} seconds")
|
|
90
109
|
return self.desktop_state
|
|
91
110
|
|
|
92
|
-
def
|
|
111
|
+
def get_window_status(self,control:uia.Control)->Status:
|
|
93
112
|
if uia.IsIconic(control.NativeWindowHandle):
|
|
94
113
|
return Status.MINIMIZED
|
|
95
114
|
elif uia.IsZoomed(control.NativeWindowHandle):
|
|
@@ -117,8 +136,8 @@ class Desktop:
|
|
|
117
136
|
try:
|
|
118
137
|
reader = csv.DictReader(io.StringIO(apps_info.strip()))
|
|
119
138
|
return {
|
|
120
|
-
row.get('Name').lower(): row.get('AppID')
|
|
121
|
-
for row in reader
|
|
139
|
+
row.get('Name', '').lower(): row.get('AppID', '')
|
|
140
|
+
for row in reader
|
|
122
141
|
if row.get('Name') and row.get('AppID')
|
|
123
142
|
}
|
|
124
143
|
except Exception as e:
|
|
@@ -129,10 +148,11 @@ class Desktop:
|
|
|
129
148
|
try:
|
|
130
149
|
encoded = base64.b64encode(command.encode("utf-16le")).decode("ascii")
|
|
131
150
|
result = subprocess.run(
|
|
132
|
-
['powershell', '-NoProfile', '-EncodedCommand', encoded],
|
|
151
|
+
['powershell', '-NoProfile', '-OutputFormat', 'Text', '-EncodedCommand', encoded],
|
|
133
152
|
capture_output=True, # No errors='ignore' - let subprocess return bytes
|
|
134
153
|
timeout=timeout,
|
|
135
|
-
cwd=os.path.expanduser(path='~')
|
|
154
|
+
cwd=os.path.expanduser(path='~'),
|
|
155
|
+
env=os.environ.copy() # Inherit environment variables including PATH
|
|
136
156
|
)
|
|
137
157
|
# Handle both bytes and str output (subprocess behavior varies by environment)
|
|
138
158
|
stdout = result.stdout
|
|
@@ -147,11 +167,11 @@ class Desktop:
|
|
|
147
167
|
except Exception as e:
|
|
148
168
|
return (f'Command execution failed: {type(e).__name__}: {e}', 1)
|
|
149
169
|
|
|
150
|
-
def
|
|
170
|
+
def is_window_browser(self,node:uia.Control):
|
|
151
171
|
'''Give any node of the app and it will return True if the app is a browser, False otherwise.'''
|
|
152
172
|
try:
|
|
153
173
|
process=Process(node.ProcessId)
|
|
154
|
-
return process.name()
|
|
174
|
+
return Browser.has_process(process.name())
|
|
155
175
|
except:
|
|
156
176
|
return False
|
|
157
177
|
|
|
@@ -162,32 +182,32 @@ class Desktop:
|
|
|
162
182
|
return "".join([row.get('DisplayName') for row in reader])
|
|
163
183
|
|
|
164
184
|
def resize_app(self,size:tuple[int,int]=None,loc:tuple[int,int]=None)->tuple[str,int]:
|
|
165
|
-
|
|
166
|
-
if
|
|
167
|
-
return "No active
|
|
168
|
-
if
|
|
169
|
-
return f"{
|
|
170
|
-
elif
|
|
171
|
-
return f"{
|
|
185
|
+
active_window=self.desktop_state.active_window
|
|
186
|
+
if active_window is None:
|
|
187
|
+
return "No active window found",1
|
|
188
|
+
if active_window.status==Status.MINIMIZED:
|
|
189
|
+
return f"{active_window.name} is minimized",1
|
|
190
|
+
elif active_window.status==Status.MAXIMIZED:
|
|
191
|
+
return f"{active_window.name} is maximized",1
|
|
172
192
|
else:
|
|
173
|
-
|
|
193
|
+
window_control=uia.ControlFromHandle(active_window.handle)
|
|
174
194
|
if loc is None:
|
|
175
|
-
x=
|
|
176
|
-
y=
|
|
195
|
+
x=window_control.BoundingRectangle.left
|
|
196
|
+
y=window_control.BoundingRectangle.top
|
|
177
197
|
loc=(x,y)
|
|
178
198
|
if size is None:
|
|
179
|
-
width=
|
|
180
|
-
height=
|
|
199
|
+
width=window_control.BoundingRectangle.width()
|
|
200
|
+
height=window_control.BoundingRectangle.height()
|
|
181
201
|
size=(width,height)
|
|
182
202
|
x,y=loc
|
|
183
203
|
width,height=size
|
|
184
|
-
|
|
185
|
-
return (f'{
|
|
204
|
+
window_control.MoveWindow(x,y,width,height)
|
|
205
|
+
return (f'{active_window.name} resized to {width}x{height} at {x},{y}.',0)
|
|
186
206
|
|
|
187
207
|
def is_app_running(self,name:str)->bool:
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
return process.extractOne(name,list(
|
|
208
|
+
windows, _ = self.get_windows()
|
|
209
|
+
windows_dict = {window.name: window for window in windows}
|
|
210
|
+
return process.extractOne(name,list(windows_dict.keys()),score_cutoff=60) is not None
|
|
191
211
|
|
|
192
212
|
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
213
|
match mode:
|
|
@@ -207,7 +227,7 @@ class Desktop:
|
|
|
207
227
|
safe_name = re.escape(name)
|
|
208
228
|
if uia.WindowControl(RegexName=f'(?i).*{safe_name}.*').Exists(maxSearchSeconds=10):
|
|
209
229
|
launched = True
|
|
210
|
-
|
|
230
|
+
|
|
211
231
|
if launched:
|
|
212
232
|
return f'{name.title()} launched.'
|
|
213
233
|
return f'Launching {name.title()} sent, but window not detected yet.'
|
|
@@ -232,37 +252,41 @@ class Desktop:
|
|
|
232
252
|
app_name,_=matched_app
|
|
233
253
|
appid=apps_map.get(app_name)
|
|
234
254
|
if appid is None:
|
|
235
|
-
return (
|
|
255
|
+
return (f'{name.title()} not found in start menu.',1,0)
|
|
236
256
|
|
|
237
257
|
pid = 0
|
|
238
258
|
if os.path.exists(appid) or "\\" in appid:
|
|
239
259
|
# It's a file path, we can try to get the PID using PassThru
|
|
240
|
-
|
|
260
|
+
# Escape any single quotes and wrap in single quotes for PowerShell safety
|
|
261
|
+
safe_appid = appid.replace("'", "''")
|
|
262
|
+
command = f"Start-Process '{safe_appid}' -PassThru | Select-Object -ExpandProperty Id"
|
|
241
263
|
response, status = self.execute_command(command)
|
|
242
264
|
if status == 0 and response.strip().isdigit():
|
|
243
265
|
pid = int(response.strip())
|
|
244
266
|
else:
|
|
245
|
-
# It's an AUMID (Store App)
|
|
267
|
+
# It's an AUMID (Store App) - validate it only contains expected characters
|
|
268
|
+
if not appid.replace('\\', '').replace('_', '').replace('.', '').replace('-', '').isalnum():
|
|
269
|
+
return (f'Invalid app identifier: {appid}', 1, 0)
|
|
246
270
|
command = f'Start-Process "shell:AppsFolder\\{appid}"'
|
|
247
271
|
response, status = self.execute_command(command)
|
|
248
272
|
|
|
249
273
|
return response, status, pid
|
|
250
274
|
|
|
251
275
|
def switch_app(self,name:str):
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
if
|
|
276
|
+
windows={window.name:window for window in [self.desktop_state.active_window]+self.desktop_state.windows if window is not None}
|
|
277
|
+
matched_window:Optional[tuple[str,float]]=process.extractOne(name,list(windows.keys()),score_cutoff=70)
|
|
278
|
+
if matched_window is None:
|
|
255
279
|
return (f'Application {name.title()} not found.',1)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
target_handle=
|
|
280
|
+
window_name,_=matched_window
|
|
281
|
+
window=windows.get(window_name)
|
|
282
|
+
target_handle=window.handle
|
|
259
283
|
|
|
260
284
|
if uia.IsIconic(target_handle):
|
|
261
285
|
uia.ShowWindow(target_handle, win32con.SW_RESTORE)
|
|
262
|
-
content=f'{
|
|
286
|
+
content=f'{window_name.title()} restored from Minimized state.'
|
|
263
287
|
else:
|
|
264
288
|
self.bring_window_to_top(target_handle)
|
|
265
|
-
content=f'Switched to {
|
|
289
|
+
content=f'Switched to {window_name.title()} window.'
|
|
266
290
|
return content,0
|
|
267
291
|
|
|
268
292
|
def bring_window_to_top(self, target_handle: int):
|
|
@@ -274,6 +298,14 @@ class Desktop:
|
|
|
274
298
|
win32gui.ShowWindow(target_handle, win32con.SW_RESTORE)
|
|
275
299
|
|
|
276
300
|
foreground_handle = win32gui.GetForegroundWindow()
|
|
301
|
+
|
|
302
|
+
# Validate both handles before proceeding
|
|
303
|
+
if not win32gui.IsWindow(foreground_handle):
|
|
304
|
+
# No valid foreground window, just try to set target as foreground
|
|
305
|
+
win32gui.SetForegroundWindow(target_handle)
|
|
306
|
+
win32gui.BringWindowToTop(target_handle)
|
|
307
|
+
return
|
|
308
|
+
|
|
277
309
|
foreground_thread, _ = win32process.GetWindowThreadProcessId(foreground_handle)
|
|
278
310
|
target_thread, _ = win32process.GetWindowThreadProcessId(target_handle)
|
|
279
311
|
|
|
@@ -322,7 +354,7 @@ class Desktop:
|
|
|
322
354
|
x,y=loc
|
|
323
355
|
pg.click(x,y,button=button,clicks=clicks,duration=0.1)
|
|
324
356
|
|
|
325
|
-
def type(self,loc:tuple[int,int],text:str,caret_position:Literal['start','
|
|
357
|
+
def type(self,loc:tuple[int,int],text:str,caret_position:Literal['start', 'idle', 'end']='idle',clear:bool|str=False,press_enter:bool|str=False):
|
|
326
358
|
x,y=loc
|
|
327
359
|
pg.leftClick(x,y)
|
|
328
360
|
if caret_position == 'start':
|
|
@@ -331,12 +363,16 @@ class Desktop:
|
|
|
331
363
|
pg.press('end')
|
|
332
364
|
else:
|
|
333
365
|
pass
|
|
334
|
-
|
|
366
|
+
|
|
367
|
+
# Handle both boolean and string 'true'/'false'
|
|
368
|
+
if clear is True or (isinstance(clear, str) and clear.lower() == 'true'):
|
|
335
369
|
pg.sleep(0.5)
|
|
336
370
|
pg.hotkey('ctrl','a')
|
|
337
371
|
pg.press('backspace')
|
|
372
|
+
|
|
338
373
|
pg.typewrite(text,interval=0.02)
|
|
339
|
-
|
|
374
|
+
|
|
375
|
+
if press_enter is True or (isinstance(press_enter, str) and press_enter.lower() == 'true'):
|
|
340
376
|
pg.press('enter')
|
|
341
377
|
|
|
342
378
|
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:
|
|
@@ -387,19 +423,20 @@ class Desktop:
|
|
|
387
423
|
else:
|
|
388
424
|
pg.press(''.join(shortcut))
|
|
389
425
|
|
|
390
|
-
def multi_select(self,press_ctrl:
|
|
391
|
-
|
|
426
|
+
def multi_select(self,press_ctrl:bool|str=False,locs:list[tuple[int,int]]=[]):
|
|
427
|
+
press_ctrl = press_ctrl is True or (isinstance(press_ctrl, str) and press_ctrl.lower() == 'true')
|
|
428
|
+
if press_ctrl:
|
|
392
429
|
pg.keyDown('ctrl')
|
|
393
|
-
for
|
|
394
|
-
x,y=
|
|
430
|
+
for loc in locs:
|
|
431
|
+
x,y=loc
|
|
395
432
|
pg.click(x,y,duration=0.2)
|
|
396
433
|
pg.sleep(0.5)
|
|
397
434
|
pg.keyUp('ctrl')
|
|
398
435
|
|
|
399
|
-
def multi_edit(self,
|
|
400
|
-
for
|
|
401
|
-
x,y,text=
|
|
402
|
-
self.type((x,y),text=text,clear=
|
|
436
|
+
def multi_edit(self,locs:list[tuple[int,int,str]]):
|
|
437
|
+
for loc in locs:
|
|
438
|
+
x,y,text=loc
|
|
439
|
+
self.type((x,y),text=text,clear=True)
|
|
403
440
|
|
|
404
441
|
def scrape(self,url:str)->str:
|
|
405
442
|
response=requests.get(url,timeout=10)
|
|
@@ -407,56 +444,105 @@ class Desktop:
|
|
|
407
444
|
content=markdownify(html=html)
|
|
408
445
|
return content
|
|
409
446
|
|
|
410
|
-
def
|
|
447
|
+
def get_window_from_element(self,element:uia.Control)->Window|None:
|
|
411
448
|
if element is None:
|
|
412
449
|
return None
|
|
413
450
|
top_window=element.GetTopLevelControl()
|
|
414
451
|
if top_window is None:
|
|
415
452
|
return None
|
|
416
453
|
handle=top_window.NativeWindowHandle
|
|
417
|
-
|
|
418
|
-
for
|
|
419
|
-
if
|
|
420
|
-
return
|
|
454
|
+
windows,_=self.get_windows()
|
|
455
|
+
for window in windows:
|
|
456
|
+
if window.handle==handle:
|
|
457
|
+
return window
|
|
421
458
|
return None
|
|
422
459
|
|
|
423
|
-
def
|
|
424
|
-
is_minimized=self.
|
|
425
|
-
size=
|
|
460
|
+
def is_window_visible(self,window:uia.Control)->bool:
|
|
461
|
+
is_minimized=self.get_window_status(window)!=Status.MINIMIZED
|
|
462
|
+
size=window.BoundingRectangle
|
|
426
463
|
area=size.width()*size.height()
|
|
427
|
-
is_overlay=self.
|
|
464
|
+
is_overlay=self.is_overlay_window(window)
|
|
428
465
|
return not is_overlay and is_minimized and area>10
|
|
429
466
|
|
|
430
|
-
def
|
|
467
|
+
def is_overlay_window(self,element:uia.Control) -> bool:
|
|
431
468
|
no_children = len(element.GetChildren()) == 0
|
|
432
469
|
is_name = "Overlay" in element.Name.strip()
|
|
433
470
|
return no_children or is_name
|
|
434
471
|
|
|
435
|
-
def get_controls_handles(self):
|
|
472
|
+
def get_controls_handles(self,optimized:bool=False):
|
|
436
473
|
handles = set()
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
474
|
+
# For even more faster results (still under development)
|
|
475
|
+
def callback(hwnd, _):
|
|
476
|
+
try:
|
|
477
|
+
# Validate handle before checking properties
|
|
478
|
+
if win32gui.IsWindow(hwnd) and win32gui.IsWindowVisible(hwnd) and is_window_on_current_desktop(hwnd):
|
|
479
|
+
handles.add(hwnd)
|
|
480
|
+
except Exception:
|
|
481
|
+
# Skip invalid handles without logging (common during window enumeration)
|
|
482
|
+
pass
|
|
483
|
+
|
|
484
|
+
win32gui.EnumWindows(callback, None)
|
|
485
|
+
|
|
486
|
+
if desktop_hwnd:= win32gui.FindWindow('Progman',None):
|
|
487
|
+
handles.add(desktop_hwnd)
|
|
488
|
+
if taskbar_hwnd:= win32gui.FindWindow('Shell_TrayWnd',None):
|
|
489
|
+
handles.add(taskbar_hwnd)
|
|
490
|
+
if secondary_taskbar_hwnd:= win32gui.FindWindow('Shell_SecondaryTrayWnd',None):
|
|
491
|
+
handles.add(secondary_taskbar_hwnd)
|
|
441
492
|
return handles
|
|
442
493
|
|
|
443
|
-
def
|
|
494
|
+
def get_active_window(self,windows:list[Window]|None=None)->Window|None:
|
|
444
495
|
try:
|
|
445
|
-
if
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
496
|
+
if windows is None:
|
|
497
|
+
windows,_=self.get_windows()
|
|
498
|
+
active_window=self.get_foreground_window()
|
|
499
|
+
if active_window.ClassName=="Progman":
|
|
500
|
+
return None
|
|
501
|
+
active_window_handle=active_window.NativeWindowHandle
|
|
502
|
+
for window in windows:
|
|
503
|
+
if window.handle!=active_window_handle:
|
|
450
504
|
continue
|
|
451
|
-
return
|
|
505
|
+
return window
|
|
506
|
+
# In case active window is not present in the windows list
|
|
507
|
+
return Window(**{
|
|
508
|
+
"name":active_window.Name,
|
|
509
|
+
"is_browser":self.is_window_browser(active_window),
|
|
510
|
+
"depth":0,
|
|
511
|
+
"bounding_box":BoundingBox(
|
|
512
|
+
left=active_window.BoundingRectangle.left,
|
|
513
|
+
top=active_window.BoundingRectangle.top,
|
|
514
|
+
right=active_window.BoundingRectangle.right,
|
|
515
|
+
bottom=active_window.BoundingRectangle.bottom,
|
|
516
|
+
width=active_window.BoundingRectangle.width(),
|
|
517
|
+
height=active_window.BoundingRectangle.height()
|
|
518
|
+
),
|
|
519
|
+
"status":self.get_window_status(active_window),
|
|
520
|
+
"handle":active_window_handle,
|
|
521
|
+
"process_id":active_window.ProcessId,
|
|
522
|
+
})
|
|
452
523
|
except Exception as ex:
|
|
453
|
-
logger.error(f"Error in
|
|
524
|
+
logger.error(f"Error in get_active_window: {ex}")
|
|
454
525
|
return None
|
|
526
|
+
|
|
527
|
+
def get_foreground_window(self)->uia.Control:
|
|
528
|
+
handle=uia.GetForegroundWindow()
|
|
529
|
+
active_window=self.get_window_from_element_handle(handle)
|
|
530
|
+
return active_window
|
|
531
|
+
|
|
532
|
+
def get_window_from_element_handle(self, element_handle: int) -> uia.Control:
|
|
533
|
+
current = uia.ControlFromHandle(element_handle)
|
|
534
|
+
root_handle = uia.GetRootControl().NativeWindowHandle
|
|
455
535
|
|
|
456
|
-
|
|
536
|
+
while True:
|
|
537
|
+
parent = current.GetParentControl()
|
|
538
|
+
if parent is None or parent.NativeWindowHandle == root_handle:
|
|
539
|
+
return current
|
|
540
|
+
current = parent
|
|
541
|
+
|
|
542
|
+
def get_windows(self,controls_handles:set[int]|None=None) -> tuple[list[Window],set[int]]:
|
|
457
543
|
try:
|
|
458
|
-
|
|
459
|
-
|
|
544
|
+
windows = []
|
|
545
|
+
window_handles = set()
|
|
460
546
|
controls_handles=controls_handles or self.get_controls_handles()
|
|
461
547
|
for depth, hwnd in enumerate(controls_handles):
|
|
462
548
|
try:
|
|
@@ -465,7 +551,7 @@ class Desktop:
|
|
|
465
551
|
continue
|
|
466
552
|
|
|
467
553
|
# Filter out Overlays (e.g. NVIDIA, Steam)
|
|
468
|
-
if self.
|
|
554
|
+
if self.is_overlay_window(child):
|
|
469
555
|
continue
|
|
470
556
|
|
|
471
557
|
if isinstance(child,(uia.WindowControl,uia.PaneControl)):
|
|
@@ -474,15 +560,14 @@ class Desktop:
|
|
|
474
560
|
continue
|
|
475
561
|
|
|
476
562
|
if window_pattern.CanMinimize and window_pattern.CanMaximize:
|
|
477
|
-
status = self.
|
|
563
|
+
status = self.get_window_status(child)
|
|
478
564
|
|
|
479
565
|
bounding_rect=child.BoundingRectangle
|
|
480
566
|
if bounding_rect.isempty() and status!=Status.MINIMIZED:
|
|
481
567
|
continue
|
|
482
568
|
|
|
483
|
-
|
|
569
|
+
windows.append(Window(**{
|
|
484
570
|
"name":child.Name,
|
|
485
|
-
"runtime_id":tuple(child.GetRuntimeId()),
|
|
486
571
|
"depth":depth,
|
|
487
572
|
"status":status,
|
|
488
573
|
"bounding_box":BoundingBox(
|
|
@@ -495,13 +580,13 @@ class Desktop:
|
|
|
495
580
|
),
|
|
496
581
|
"handle":child.NativeWindowHandle,
|
|
497
582
|
"process_id":child.ProcessId,
|
|
498
|
-
"is_browser":self.
|
|
583
|
+
"is_browser":self.is_window_browser(child)
|
|
499
584
|
}))
|
|
500
|
-
|
|
585
|
+
window_handles.add(child.NativeWindowHandle)
|
|
501
586
|
except Exception as ex:
|
|
502
|
-
logger.error(f"Error in
|
|
503
|
-
|
|
504
|
-
return
|
|
587
|
+
logger.error(f"Error in get_windows: {ex}")
|
|
588
|
+
windows = []
|
|
589
|
+
return windows,window_handles
|
|
505
590
|
|
|
506
591
|
def get_xpath_from_element(self,element:uia.Control):
|
|
507
592
|
current=element
|
|
@@ -556,9 +641,13 @@ class Desktop:
|
|
|
556
641
|
return "Local Account" if response.strip()=='Local' else "Microsoft Account" if status==0 else "Local Account"
|
|
557
642
|
|
|
558
643
|
def get_dpi_scaling(self):
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
644
|
+
try:
|
|
645
|
+
user32 = ctypes.windll.user32
|
|
646
|
+
dpi = user32.GetDpiForSystem()
|
|
647
|
+
return dpi / 96.0 if dpi > 0 else 1.0
|
|
648
|
+
except Exception:
|
|
649
|
+
# Fallback to standard DPI if system call fails
|
|
650
|
+
return 1.0
|
|
562
651
|
|
|
563
652
|
def get_screen_size(self)->Size:
|
|
564
653
|
width, height = uia.GetVirtualScreenSize()
|
|
@@ -568,12 +657,11 @@ class Desktop:
|
|
|
568
657
|
try:
|
|
569
658
|
return ImageGrab.grab(all_screens=True)
|
|
570
659
|
except Exception as e:
|
|
571
|
-
logger.warning(f"Failed to capture
|
|
660
|
+
logger.warning(f"Failed to capture virtual screen, using primary screen")
|
|
572
661
|
return pg.screenshot()
|
|
573
662
|
|
|
574
663
|
def get_annotated_screenshot(self, nodes: list[TreeElementNode]) -> Image.Image:
|
|
575
664
|
screenshot = self.get_screenshot()
|
|
576
|
-
sleep(0.10)
|
|
577
665
|
# Add padding
|
|
578
666
|
padding = 5
|
|
579
667
|
width = int(screenshot.width + (1.5 * padding))
|
windows_mcp/desktop/views.py
CHANGED
|
@@ -6,9 +6,15 @@ from PIL.Image import Image
|
|
|
6
6
|
from enum import Enum
|
|
7
7
|
|
|
8
8
|
class Browser(Enum):
|
|
9
|
-
CHROME='
|
|
10
|
-
EDGE='
|
|
11
|
-
FIREFOX='
|
|
9
|
+
CHROME='chrome'
|
|
10
|
+
EDGE='msedge'
|
|
11
|
+
FIREFOX='firefox'
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def has_process(cls, process_name: str) -> bool:
|
|
15
|
+
if not hasattr(cls, '_process_names'):
|
|
16
|
+
cls._process_names = {f'{b.value}.exe' for b in cls}
|
|
17
|
+
return process_name.lower() in cls._process_names
|
|
12
18
|
|
|
13
19
|
class Status(Enum):
|
|
14
20
|
MAXIMIZED='Maximized'
|
|
@@ -18,9 +24,8 @@ class Status(Enum):
|
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
@dataclass
|
|
21
|
-
class
|
|
27
|
+
class Window:
|
|
22
28
|
name:str
|
|
23
|
-
runtime_id:tuple[int]
|
|
24
29
|
is_browser:bool
|
|
25
30
|
depth:int
|
|
26
31
|
status:Status
|
|
@@ -41,20 +46,32 @@ class Size:
|
|
|
41
46
|
|
|
42
47
|
@dataclass
|
|
43
48
|
class DesktopState:
|
|
44
|
-
|
|
45
|
-
|
|
49
|
+
active_desktop:dict
|
|
50
|
+
all_desktops:list[dict]
|
|
51
|
+
active_window:Optional[Window]
|
|
52
|
+
windows:list[Window]
|
|
46
53
|
screenshot:Optional[Image]=None
|
|
47
54
|
tree_state:Optional[TreeState]=None
|
|
48
55
|
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
def active_desktop_to_string(self):
|
|
57
|
+
desktop_name=self.active_desktop.get('name')
|
|
58
|
+
headers=["Name"]
|
|
59
|
+
return tabulate([[desktop_name]], headers=headers, tablefmt="simple")
|
|
60
|
+
|
|
61
|
+
def desktops_to_string(self):
|
|
62
|
+
headers=["Name"]
|
|
63
|
+
rows=[[desktop.get("name")] for desktop in self.all_desktops]
|
|
64
|
+
return tabulate(rows, headers=headers, tablefmt="simple")
|
|
65
|
+
|
|
66
|
+
def active_window_to_string(self):
|
|
67
|
+
if not self.active_window:
|
|
68
|
+
return 'No active window found'
|
|
52
69
|
headers = ["Name", "Depth", "Status", "Width", "Height", "Handle"]
|
|
53
|
-
return tabulate([self.
|
|
70
|
+
return tabulate([self.active_window.to_row()], headers=headers, tablefmt="simple")
|
|
54
71
|
|
|
55
|
-
def
|
|
56
|
-
if not self.
|
|
57
|
-
return 'No
|
|
72
|
+
def windows_to_string(self):
|
|
73
|
+
if not self.windows:
|
|
74
|
+
return 'No windows found'
|
|
58
75
|
headers = ["Name", "Depth", "Status", "Width", "Height", "Handle"]
|
|
59
|
-
rows = [
|
|
76
|
+
rows = [window.to_row() for window in self.windows]
|
|
60
77
|
return tabulate(rows, headers=headers, tablefmt="simple")
|