roblox-studio-physical-operation-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,578 @@
1
+ """
2
+ Windows 工具模块: 窗口查找、按键发送、截图等
3
+ """
4
+
5
+ import ctypes
6
+ from ctypes import wintypes, Structure, Union, POINTER
7
+ import time
8
+ from typing import Optional
9
+ import winreg
10
+
11
+ # 设置 DPI 感知 (必须在任何 GUI 操作之前调用)
12
+ # PROCESS_PER_MONITOR_DPI_AWARE = 2
13
+ try:
14
+ ctypes.windll.shcore.SetProcessDpiAwareness(2)
15
+ except Exception:
16
+ # Windows 8.1 之前的系统使用旧 API
17
+ try:
18
+ ctypes.windll.user32.SetProcessDPIAware()
19
+ except Exception:
20
+ pass
21
+
22
+ # Windows API
23
+ user32 = ctypes.windll.user32
24
+
25
+ # SendInput 常量
26
+ INPUT_KEYBOARD = 1
27
+ KEYEVENTF_KEYUP = 0x0002
28
+ VK_F5 = 0x74
29
+ VK_F12 = 0x7B
30
+ VK_SHIFT = 0x10
31
+
32
+
33
+ # SendInput 结构体
34
+ class KEYBDINPUT(Structure):
35
+ _fields_ = [
36
+ ("wVk", wintypes.WORD),
37
+ ("wScan", wintypes.WORD),
38
+ ("dwFlags", wintypes.DWORD),
39
+ ("time", wintypes.DWORD),
40
+ ("dwExtraInfo", POINTER(ctypes.c_ulong))
41
+ ]
42
+
43
+
44
+ class MOUSEINPUT(Structure):
45
+ _fields_ = [
46
+ ("dx", wintypes.LONG),
47
+ ("dy", wintypes.LONG),
48
+ ("mouseData", wintypes.DWORD),
49
+ ("dwFlags", wintypes.DWORD),
50
+ ("time", wintypes.DWORD),
51
+ ("dwExtraInfo", POINTER(ctypes.c_ulong))
52
+ ]
53
+
54
+
55
+ class HARDWAREINPUT(Structure):
56
+ _fields_ = [
57
+ ("uMsg", wintypes.DWORD),
58
+ ("wParamL", wintypes.WORD),
59
+ ("wParamH", wintypes.WORD)
60
+ ]
61
+
62
+
63
+ class INPUT_UNION(Union):
64
+ _fields_ = [
65
+ ("ki", KEYBDINPUT),
66
+ ("mi", MOUSEINPUT),
67
+ ("hi", HARDWAREINPUT)
68
+ ]
69
+
70
+
71
+ class INPUT(Structure):
72
+ _fields_ = [
73
+ ("type", wintypes.DWORD),
74
+ ("union", INPUT_UNION)
75
+ ]
76
+
77
+
78
+ def get_studio_path() -> Optional[str]:
79
+ """从注册表获取 Roblox Studio 路径"""
80
+ try:
81
+ key = winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"roblox-studio\shell\open\command")
82
+ value, _ = winreg.QueryValueEx(key, "")
83
+ winreg.CloseKey(key)
84
+ # 值格式: "C:\...\RobloxStudioBeta.exe" %1
85
+ # 提取路径
86
+ if value.startswith('"'):
87
+ return value.split('"')[1]
88
+ return value.split()[0]
89
+ except FileNotFoundError:
90
+ return None
91
+
92
+
93
+ def is_window_valid(hwnd: int) -> bool:
94
+ """检查窗口句柄是否有效"""
95
+ return bool(user32.IsWindow(hwnd))
96
+
97
+
98
+ def find_window_by_title(title_contains: str) -> Optional[int]:
99
+ """通过标题查找窗口"""
100
+ result = []
101
+ EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
102
+
103
+ def enum_callback(hwnd, lparam):
104
+ if user32.IsWindowVisible(hwnd):
105
+ length = user32.GetWindowTextLengthW(hwnd)
106
+ if length > 0:
107
+ buffer = ctypes.create_unicode_buffer(length + 1)
108
+ user32.GetWindowTextW(hwnd, buffer, length + 1)
109
+ if title_contains in buffer.value:
110
+ result.append(hwnd)
111
+ return True
112
+
113
+ user32.EnumWindows(EnumWindowsProc(enum_callback), 0)
114
+ return result[0] if result else None
115
+
116
+
117
+ def find_window_by_pid(pid: int) -> Optional[int]:
118
+ """通过 PID 查找主窗口句柄"""
119
+ result = []
120
+ EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
121
+
122
+ def enum_callback(hwnd, lparam):
123
+ if user32.IsWindowVisible(hwnd):
124
+ window_pid = wintypes.DWORD()
125
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(window_pid))
126
+ if window_pid.value == pid:
127
+ length = user32.GetWindowTextLengthW(hwnd)
128
+ if length > 0:
129
+ buffer = ctypes.create_unicode_buffer(length + 1)
130
+ user32.GetWindowTextW(hwnd, buffer, length + 1)
131
+ title = buffer.value
132
+ if "Roblox Studio" in title:
133
+ result.append(hwnd)
134
+ return True
135
+
136
+ user32.EnumWindows(EnumWindowsProc(enum_callback), 0)
137
+ return result[0] if result else None
138
+
139
+
140
+ def send_key(vk_code: int) -> bool:
141
+ """使用 SendInput 发送按键"""
142
+ inputs = (INPUT * 2)()
143
+
144
+ # Key down
145
+ inputs[0].type = INPUT_KEYBOARD
146
+ inputs[0].union.ki.wVk = vk_code
147
+ inputs[0].union.ki.wScan = 0
148
+ inputs[0].union.ki.dwFlags = 0
149
+ inputs[0].union.ki.time = 0
150
+ inputs[0].union.ki.dwExtraInfo = None
151
+
152
+ # Key up
153
+ inputs[1].type = INPUT_KEYBOARD
154
+ inputs[1].union.ki.wVk = vk_code
155
+ inputs[1].union.ki.wScan = 0
156
+ inputs[1].union.ki.dwFlags = KEYEVENTF_KEYUP
157
+ inputs[1].union.ki.time = 0
158
+ inputs[1].union.ki.dwExtraInfo = None
159
+
160
+ result = user32.SendInput(2, ctypes.byref(inputs), ctypes.sizeof(INPUT))
161
+ return result == 2
162
+
163
+
164
+ def send_key_combo(vk_codes: list[int]) -> bool:
165
+ """发送组合键 (如 Shift+F5)"""
166
+ count = len(vk_codes) * 2
167
+ inputs = (INPUT * count)()
168
+
169
+ # 所有键按下
170
+ for i, vk in enumerate(vk_codes):
171
+ inputs[i].type = INPUT_KEYBOARD
172
+ inputs[i].union.ki.wVk = vk
173
+ inputs[i].union.ki.dwFlags = 0
174
+
175
+ # 所有键释放 (逆序)
176
+ for i, vk in enumerate(reversed(vk_codes)):
177
+ idx = len(vk_codes) + i
178
+ inputs[idx].type = INPUT_KEYBOARD
179
+ inputs[idx].union.ki.wVk = vk
180
+ inputs[idx].union.ki.dwFlags = KEYEVENTF_KEYUP
181
+
182
+ result = user32.SendInput(count, ctypes.byref(inputs), ctypes.sizeof(INPUT))
183
+ return result == count
184
+
185
+
186
+ def send_key_to_window(hwnd: int, vk_code: int) -> bool:
187
+ """将窗口置于前台并发送按键"""
188
+ original_hwnd = user32.GetForegroundWindow()
189
+
190
+ # 尝试将目标窗口置于前台
191
+ user32.SetForegroundWindow(hwnd)
192
+ time.sleep(0.1)
193
+
194
+ # 检查是否成功,使用 Alt 键技巧
195
+ if user32.GetForegroundWindow() != hwnd:
196
+ user32.keybd_event(0x12, 0, 0, 0) # Alt down
197
+ user32.SetForegroundWindow(hwnd)
198
+ user32.keybd_event(0x12, 0, 2, 0) # Alt up
199
+ time.sleep(0.1)
200
+
201
+ # 发送按键
202
+ success = send_key(vk_code)
203
+ time.sleep(0.2)
204
+
205
+ # 恢复原窗口
206
+ if original_hwnd and original_hwnd != hwnd:
207
+ user32.SetForegroundWindow(original_hwnd)
208
+
209
+ return success
210
+
211
+
212
+ def send_key_combo_to_window(hwnd: int, vk_codes: list[int]) -> bool:
213
+ """将窗口置于前台并发送组合键"""
214
+ original_hwnd = user32.GetForegroundWindow()
215
+
216
+ user32.SetForegroundWindow(hwnd)
217
+ time.sleep(0.1)
218
+
219
+ if user32.GetForegroundWindow() != hwnd:
220
+ user32.keybd_event(0x12, 0, 0, 0)
221
+ user32.SetForegroundWindow(hwnd)
222
+ user32.keybd_event(0x12, 0, 2, 0)
223
+ time.sleep(0.1)
224
+
225
+ success = send_key_combo(vk_codes)
226
+ time.sleep(0.2)
227
+
228
+ if original_hwnd and original_hwnd != hwnd:
229
+ user32.SetForegroundWindow(original_hwnd)
230
+
231
+ return success
232
+
233
+
234
+ def capture_window(hwnd: int, output_path: str) -> bool:
235
+ """使用 PrintWindow 截取窗口"""
236
+ try:
237
+ import win32gui
238
+ import win32ui
239
+ from PIL import Image
240
+
241
+ rect = win32gui.GetWindowRect(hwnd)
242
+ width = rect[2] - rect[0]
243
+ height = rect[3] - rect[1]
244
+
245
+ hwnd_dc = win32gui.GetWindowDC(hwnd)
246
+ mfc_dc = win32ui.CreateDCFromHandle(hwnd_dc)
247
+ save_dc = mfc_dc.CreateCompatibleDC()
248
+
249
+ bitmap = win32ui.CreateBitmap()
250
+ bitmap.CreateCompatibleBitmap(mfc_dc, width, height)
251
+ save_dc.SelectObject(bitmap)
252
+
253
+ # PrintWindow with PW_RENDERFULLCONTENT flag
254
+ ctypes.windll.user32.PrintWindow(hwnd, save_dc.GetSafeHdc(), 2)
255
+
256
+ bmpinfo = bitmap.GetInfo()
257
+ bmpstr = bitmap.GetBitmapBits(True)
258
+ img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']),
259
+ bmpstr, 'raw', 'BGRX', 0, 1)
260
+ img.save(output_path)
261
+
262
+ win32gui.DeleteObject(bitmap.GetHandle())
263
+ save_dc.DeleteDC()
264
+ mfc_dc.DeleteDC()
265
+ win32gui.ReleaseDC(hwnd, hwnd_dc)
266
+
267
+ return True
268
+ except Exception:
269
+ return False
270
+
271
+
272
+ def click_at(hwnd: int, x: int, y: int, restore_focus: bool = True) -> bool:
273
+ """
274
+ 在窗口的指定坐标点击
275
+
276
+ Args:
277
+ hwnd: 窗口句柄
278
+ x: 相对于窗口左上角的 X 坐标
279
+ y: 相对于窗口左上角的 Y 坐标
280
+ restore_focus: 是否恢复原窗口焦点
281
+
282
+ Returns:
283
+ 是否成功
284
+ """
285
+ try:
286
+ import win32gui
287
+ import win32api
288
+ import win32con
289
+
290
+ # 保存当前前台窗口
291
+ original_hwnd = user32.GetForegroundWindow() if restore_focus else None
292
+
293
+ # 获取窗口位置
294
+ rect = win32gui.GetWindowRect(hwnd)
295
+ abs_x = rect[0] + x
296
+ abs_y = rect[1] + y
297
+
298
+ # 将窗口置于前台
299
+ user32.SetForegroundWindow(hwnd)
300
+ time.sleep(0.1)
301
+
302
+ if user32.GetForegroundWindow() != hwnd:
303
+ user32.keybd_event(0x12, 0, 0, 0) # Alt down
304
+ user32.SetForegroundWindow(hwnd)
305
+ user32.keybd_event(0x12, 0, 2, 0) # Alt up
306
+ time.sleep(0.1)
307
+
308
+ # 移动鼠标并点击
309
+ win32api.SetCursorPos((abs_x, abs_y))
310
+ time.sleep(0.05)
311
+ win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
312
+ time.sleep(0.05)
313
+ win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
314
+
315
+ time.sleep(0.1)
316
+
317
+ # 恢复原窗口
318
+ if original_hwnd and original_hwnd != hwnd:
319
+ user32.SetForegroundWindow(original_hwnd)
320
+
321
+ return True
322
+ except Exception:
323
+ return False
324
+
325
+
326
+ def right_click_at(hwnd: int, x: int, y: int, restore_focus: bool = True) -> bool:
327
+ """
328
+ 在窗口的指定坐标右键点击
329
+
330
+ Args:
331
+ hwnd: 窗口句柄
332
+ x: 相对于窗口左上角的 X 坐标
333
+ y: 相对于窗口左上角的 Y 坐标
334
+ restore_focus: 是否恢复原窗口焦点
335
+
336
+ Returns:
337
+ 是否成功
338
+ """
339
+ try:
340
+ import win32gui
341
+ import win32api
342
+ import win32con
343
+
344
+ original_hwnd = user32.GetForegroundWindow() if restore_focus else None
345
+
346
+ rect = win32gui.GetWindowRect(hwnd)
347
+ abs_x = rect[0] + x
348
+ abs_y = rect[1] + y
349
+
350
+ user32.SetForegroundWindow(hwnd)
351
+ time.sleep(0.1)
352
+
353
+ if user32.GetForegroundWindow() != hwnd:
354
+ user32.keybd_event(0x12, 0, 0, 0)
355
+ user32.SetForegroundWindow(hwnd)
356
+ user32.keybd_event(0x12, 0, 2, 0)
357
+ time.sleep(0.1)
358
+
359
+ win32api.SetCursorPos((abs_x, abs_y))
360
+ time.sleep(0.05)
361
+ win32api.mouse_event(win32con.MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0)
362
+ time.sleep(0.05)
363
+ win32api.mouse_event(win32con.MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0)
364
+
365
+ time.sleep(0.1)
366
+
367
+ if original_hwnd and original_hwnd != hwnd:
368
+ user32.SetForegroundWindow(original_hwnd)
369
+
370
+ return True
371
+ except Exception:
372
+ return False
373
+
374
+
375
+ def double_click_at(hwnd: int, x: int, y: int, restore_focus: bool = True) -> bool:
376
+ """
377
+ 在窗口的指定坐标双击
378
+
379
+ Args:
380
+ hwnd: 窗口句柄
381
+ x: 相对于窗口左上角的 X 坐标
382
+ y: 相对于窗口左上角的 Y 坐标
383
+ restore_focus: 是否恢复原窗口焦点
384
+
385
+ Returns:
386
+ 是否成功
387
+ """
388
+ try:
389
+ import win32gui
390
+ import win32api
391
+ import win32con
392
+
393
+ original_hwnd = user32.GetForegroundWindow() if restore_focus else None
394
+
395
+ rect = win32gui.GetWindowRect(hwnd)
396
+ abs_x = rect[0] + x
397
+ abs_y = rect[1] + y
398
+
399
+ user32.SetForegroundWindow(hwnd)
400
+ time.sleep(0.1)
401
+
402
+ if user32.GetForegroundWindow() != hwnd:
403
+ user32.keybd_event(0x12, 0, 0, 0)
404
+ user32.SetForegroundWindow(hwnd)
405
+ user32.keybd_event(0x12, 0, 2, 0)
406
+ time.sleep(0.1)
407
+
408
+ win32api.SetCursorPos((abs_x, abs_y))
409
+ time.sleep(0.05)
410
+
411
+ # 双击
412
+ for _ in range(2):
413
+ win32api.mouse_event(win32con.MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
414
+ time.sleep(0.02)
415
+ win32api.mouse_event(win32con.MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
416
+ time.sleep(0.05)
417
+
418
+ time.sleep(0.1)
419
+
420
+ if original_hwnd and original_hwnd != hwnd:
421
+ user32.SetForegroundWindow(original_hwnd)
422
+
423
+ return True
424
+ except Exception:
425
+ return False
426
+
427
+
428
+ def find_all_windows_by_pid(pid: int) -> list[dict]:
429
+ """
430
+ 查找指定进程的所有可见窗口
431
+
432
+ Args:
433
+ pid: 进程 ID
434
+
435
+ Returns:
436
+ 窗口信息列表,每个元素包含 hwnd, title, rect
437
+ """
438
+ windows = []
439
+ EnumWindowsProc = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)
440
+
441
+ def enum_callback(hwnd, lparam):
442
+ if user32.IsWindowVisible(hwnd):
443
+ window_pid = wintypes.DWORD()
444
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(window_pid))
445
+ if window_pid.value == pid:
446
+ # 获取窗口标题
447
+ length = user32.GetWindowTextLengthW(hwnd)
448
+ title = ""
449
+ if length > 0:
450
+ buffer = ctypes.create_unicode_buffer(length + 1)
451
+ user32.GetWindowTextW(hwnd, buffer, length + 1)
452
+ title = buffer.value
453
+
454
+ # 获取窗口位置
455
+ import win32gui
456
+ try:
457
+ rect = win32gui.GetWindowRect(hwnd)
458
+ # 过滤掉无效窗口 (宽高为0)
459
+ width = rect[2] - rect[0]
460
+ height = rect[3] - rect[1]
461
+ if width > 0 and height > 0:
462
+ windows.append({
463
+ 'hwnd': hwnd,
464
+ 'title': title,
465
+ 'rect': rect,
466
+ 'width': width,
467
+ 'height': height
468
+ })
469
+ except Exception:
470
+ pass
471
+ return True
472
+
473
+ user32.EnumWindows(EnumWindowsProc(enum_callback), 0)
474
+ return windows
475
+
476
+
477
+ def capture_window_with_modals(hwnd: int, pid: int, output_path: str) -> tuple[bool, list[dict]]:
478
+ """
479
+ 截取窗口,优先截取模态弹窗
480
+
481
+ 通过查找同一进程的所有窗口,如果存在模态弹窗(非主窗口),
482
+ 则只截取模态弹窗;否则截取主窗口。
483
+
484
+ Args:
485
+ hwnd: 主窗口句柄
486
+ pid: 进程 ID
487
+ output_path: 输出图片路径
488
+
489
+ Returns:
490
+ (success, windows_info) - 是否成功,以及捕获到的窗口信息列表
491
+ """
492
+ try:
493
+ # 查找所有同进程窗口
494
+ all_windows = find_all_windows_by_pid(pid)
495
+
496
+ if not all_windows:
497
+ # 如果没找到窗口,回退到普通截图
498
+ success = capture_window(hwnd, output_path)
499
+ return success, []
500
+
501
+ # 找出模态弹窗(非主窗口的其他窗口)
502
+ modal_windows = [w for w in all_windows if w['hwnd'] != hwnd]
503
+
504
+ if modal_windows:
505
+ # 有模态弹窗,截取第一个模态弹窗(通常是最上层的)
506
+ target_hwnd = modal_windows[0]['hwnd']
507
+ success = capture_window(target_hwnd, output_path)
508
+ return success, all_windows
509
+ else:
510
+ # 没有模态弹窗,截取主窗口
511
+ success = capture_window(hwnd, output_path)
512
+ return success, all_windows
513
+
514
+ except Exception as e:
515
+ return False, [{'error': str(e)}]
516
+
517
+
518
+ def get_modal_windows(hwnd: int, pid: int) -> list[dict]:
519
+ """
520
+ 获取模态弹窗列表(排除主窗口和小的边框窗口)
521
+
522
+ Args:
523
+ hwnd: 主窗口句柄
524
+ pid: 进程 ID
525
+
526
+ Returns:
527
+ 模态弹窗信息列表
528
+ """
529
+ all_windows = find_all_windows_by_pid(pid)
530
+
531
+ # 过滤:排除主窗口,排除小窗口(宽或高小于50的可能是边框)
532
+ modal_windows = [
533
+ w for w in all_windows
534
+ if w['hwnd'] != hwnd and w['width'] > 50 and w['height'] > 50
535
+ ]
536
+
537
+ return modal_windows
538
+
539
+
540
+ def close_modal_window(modal_hwnd: int) -> bool:
541
+ """
542
+ 关闭指定的模态弹窗
543
+
544
+ 通过发送 WM_CLOSE 消息关闭窗口
545
+
546
+ Args:
547
+ modal_hwnd: 模态弹窗句柄
548
+
549
+ Returns:
550
+ 是否成功
551
+ """
552
+ try:
553
+ WM_CLOSE = 0x0010
554
+ user32.PostMessageW(modal_hwnd, WM_CLOSE, 0, 0)
555
+ return True
556
+ except Exception:
557
+ return False
558
+
559
+
560
+ def close_all_modals(hwnd: int, pid: int) -> tuple[int, list[str]]:
561
+ """
562
+ 关闭所有模态弹窗
563
+
564
+ Args:
565
+ hwnd: 主窗口句柄
566
+ pid: 进程 ID
567
+
568
+ Returns:
569
+ (关闭数量, 关闭的窗口标题列表)
570
+ """
571
+ modal_windows = get_modal_windows(hwnd, pid)
572
+ closed_titles = []
573
+
574
+ for modal in modal_windows:
575
+ if close_modal_window(modal['hwnd']):
576
+ closed_titles.append(modal['title'] or f"(hwnd: {modal['hwnd']})")
577
+
578
+ return len(closed_titles), closed_titles