myagent-ai 1.33.0 → 1.33.2
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.
- package/aiskills/browser_stealth.py +292 -5
- package/aiskills/chromedev_mcp.py +203 -3
- package/package.json +1 -1
|
@@ -33,6 +33,7 @@ import asyncio
|
|
|
33
33
|
import json
|
|
34
34
|
import os
|
|
35
35
|
import shutil
|
|
36
|
+
import subprocess
|
|
36
37
|
import threading
|
|
37
38
|
import time
|
|
38
39
|
from pathlib import Path
|
|
@@ -44,6 +45,200 @@ from aiskills.base import Skill, SkillResult, SkillParameter
|
|
|
44
45
|
logger = get_logger("myagent.skills.browser_stealth")
|
|
45
46
|
|
|
46
47
|
|
|
48
|
+
# ── Xvfb 虚拟显示管理 ──────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
_xvfb_process: Optional[subprocess.Popen] = None
|
|
51
|
+
_xvfb_display: Optional[str] = None
|
|
52
|
+
_xvfb_lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _has_display() -> bool:
|
|
56
|
+
"""检查当前环境是否有可用的 X11 显示"""
|
|
57
|
+
display = os.environ.get("DISPLAY", "").strip()
|
|
58
|
+
if not display:
|
|
59
|
+
return False
|
|
60
|
+
# DISPLAY 已设置,尝试检测是否真的可用
|
|
61
|
+
# 方法1: xdpyinfo
|
|
62
|
+
try:
|
|
63
|
+
result = subprocess.run(
|
|
64
|
+
["xdpyinfo"],
|
|
65
|
+
capture_output=True, timeout=3,
|
|
66
|
+
env={**os.environ},
|
|
67
|
+
)
|
|
68
|
+
if result.returncode == 0:
|
|
69
|
+
return True
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
# 方法2: xdpyinfo 不可用时,尝试用 Xvfb 的 -displayfd 验证
|
|
73
|
+
# 如果 DISPLAY 变量指向一个活跃的 X server,即使 xdpyinfo 不可用,
|
|
74
|
+
# 我们也可以认为有可用显示(例如 Xvfb 已经启动过了)
|
|
75
|
+
try:
|
|
76
|
+
# 尝试连接 X server(使用 xdotool 或 xprop 等轻量工具)
|
|
77
|
+
for cmd in (["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"],
|
|
78
|
+
["xdotool", "getdisplaywidth"]):
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
cmd, capture_output=True, timeout=3,
|
|
81
|
+
env={**os.environ},
|
|
82
|
+
)
|
|
83
|
+
if result.returncode == 0:
|
|
84
|
+
return True
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
# 方法3: 如果 DISPLAY 格式正确且 /tmp/.X11-unix/ 下有对应 socket,认为可用
|
|
88
|
+
try:
|
|
89
|
+
if display.startswith(":"):
|
|
90
|
+
num = display[1:].split(".")[0]
|
|
91
|
+
socket_path = f"/tmp/.X11-unix/X{num}"
|
|
92
|
+
if os.path.exists(socket_path):
|
|
93
|
+
return True
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _start_xvfb(display_num: int = 99) -> Optional[str]:
|
|
100
|
+
"""
|
|
101
|
+
启动 Xvfb 虚拟显示服务器。
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
DISPLAY 环境变量值(如 :99),失败返回 None
|
|
105
|
+
"""
|
|
106
|
+
global _xvfb_process, _xvfb_display
|
|
107
|
+
|
|
108
|
+
with _xvfb_lock:
|
|
109
|
+
# 如果已经启动,直接返回
|
|
110
|
+
if _xvfb_process and _xvfb_process.poll() is None:
|
|
111
|
+
return _xvfb_display
|
|
112
|
+
|
|
113
|
+
# 清理旧的
|
|
114
|
+
if _xvfb_process:
|
|
115
|
+
try:
|
|
116
|
+
_xvfb_process.terminate()
|
|
117
|
+
_xvfb_process.wait(timeout=3)
|
|
118
|
+
except Exception:
|
|
119
|
+
try:
|
|
120
|
+
_xvfb_process.kill()
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
_xvfb_process = None
|
|
124
|
+
|
|
125
|
+
xvfb_path = shutil.which("Xvfb")
|
|
126
|
+
if not xvfb_path:
|
|
127
|
+
logger.warning("Xvfb 未安装,无法启动虚拟显示")
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
display_str = f":{display_num}"
|
|
131
|
+
try:
|
|
132
|
+
# 先清理可能残留的 X11 锁文件
|
|
133
|
+
for lock_file in (f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"):
|
|
134
|
+
if os.path.exists(lock_file):
|
|
135
|
+
try:
|
|
136
|
+
os.unlink(lock_file)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
_xvfb_process = subprocess.Popen(
|
|
141
|
+
[xvfb_path, display_str, "-screen", "0", "1920x1080x24", "-ac", "-nolisten", "tcp"],
|
|
142
|
+
stdout=subprocess.DEVNULL,
|
|
143
|
+
stderr=subprocess.DEVNULL,
|
|
144
|
+
)
|
|
145
|
+
# 等待 Xvfb 启动
|
|
146
|
+
time.sleep(0.5)
|
|
147
|
+
if _xvfb_process.poll() is not None:
|
|
148
|
+
logger.error(f"Xvfb 启动后立即退出 (returncode={_xvfb_process.returncode})")
|
|
149
|
+
_xvfb_process = None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
_xvfb_display = display_str
|
|
153
|
+
os.environ["DISPLAY"] = display_str
|
|
154
|
+
logger.info(f"Xvfb 虚拟显示已启动: {display_str}")
|
|
155
|
+
return display_str
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"启动 Xvfb 失败: {e}")
|
|
158
|
+
_xvfb_process = None
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _stop_xvfb() -> None:
|
|
163
|
+
"""停止 Xvfb 虚拟显示服务器"""
|
|
164
|
+
global _xvfb_process, _xvfb_display
|
|
165
|
+
|
|
166
|
+
with _xvfb_lock:
|
|
167
|
+
if _xvfb_process:
|
|
168
|
+
try:
|
|
169
|
+
_xvfb_process.terminate()
|
|
170
|
+
_xvfb_process.wait(timeout=5)
|
|
171
|
+
except Exception:
|
|
172
|
+
try:
|
|
173
|
+
_xvfb_process.kill()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
_xvfb_process = None
|
|
177
|
+
_xvfb_display = None
|
|
178
|
+
logger.info("Xvfb 虚拟显示已停止")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _ensure_display() -> Optional[Dict[str, Any]]:
|
|
182
|
+
"""
|
|
183
|
+
确保有可用的 X11 显示,供浏览器有头模式使用。
|
|
184
|
+
|
|
185
|
+
优先级:
|
|
186
|
+
1. 复用 VNC 远程桌面的 Xvfb 显示(用户可在 VNC 中看到浏览器操作)
|
|
187
|
+
2. 自动启动 VNC(含 Xvfb + x11vnc + websockify)
|
|
188
|
+
3. VNC 不可用时独立启动 Xvfb
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
{"display": ":99", "vnc": True/False, "xvfb_standalone": True/False}
|
|
192
|
+
失败返回 None
|
|
193
|
+
"""
|
|
194
|
+
# 1. 尝试复用 VNC 远程桌面
|
|
195
|
+
try:
|
|
196
|
+
from core.vnc_manager import get_vnc_manager
|
|
197
|
+
vnc = get_vnc_manager()
|
|
198
|
+
|
|
199
|
+
# VNC 已在运行 → 直接复用其 DISPLAY
|
|
200
|
+
if vnc.is_running:
|
|
201
|
+
display = vnc.display # 默认 :99
|
|
202
|
+
os.environ["DISPLAY"] = display
|
|
203
|
+
logger.info(f"VNC 远程桌面已在运行,复用显示: {display}")
|
|
204
|
+
return {"display": display, "vnc": True, "xvfb_standalone": False}
|
|
205
|
+
|
|
206
|
+
# VNC 未运行 → 尝试自动启动
|
|
207
|
+
try:
|
|
208
|
+
import asyncio
|
|
209
|
+
loop = asyncio.get_event_loop()
|
|
210
|
+
if loop.is_running():
|
|
211
|
+
# 在已有事件循环中,用 run_until_complete 不行,
|
|
212
|
+
# 创建 task 等待完成
|
|
213
|
+
import concurrent.futures
|
|
214
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
215
|
+
result = pool.submit(asyncio.run, vnc.start()).result(timeout=30)
|
|
216
|
+
else:
|
|
217
|
+
result = asyncio.run(vnc.start())
|
|
218
|
+
|
|
219
|
+
if result.get("success"):
|
|
220
|
+
display = vnc.display
|
|
221
|
+
os.environ["DISPLAY"] = display
|
|
222
|
+
logger.info(f"VNC 远程桌面已自动启动,显示: {display},可在 VNC 中查看浏览器操作")
|
|
223
|
+
return {"display": display, "vnc": True, "xvfb_standalone": False}
|
|
224
|
+
else:
|
|
225
|
+
logger.warning(f"VNC 自动启动失败: {result.get('message', '')}")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"VNC 自动启动异常: {e}")
|
|
228
|
+
|
|
229
|
+
except ImportError:
|
|
230
|
+
logger.debug("VNC 管理器不可用,跳过 VNC 集成")
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.warning(f"VNC 检测异常: {e}")
|
|
233
|
+
|
|
234
|
+
# 2. VNC 不可用 → 独立启动 Xvfb
|
|
235
|
+
xvfb_display = _start_xvfb()
|
|
236
|
+
if xvfb_display:
|
|
237
|
+
return {"display": xvfb_display, "vnc": False, "xvfb_standalone": True}
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
47
242
|
# ── 反检测浏览器管理器 ──────────────────────────────────────
|
|
48
243
|
|
|
49
244
|
|
|
@@ -80,6 +275,8 @@ class StealthBrowser:
|
|
|
80
275
|
self._browser = None # DrissionPage Chromium 实例
|
|
81
276
|
self._started = False
|
|
82
277
|
self._user_data_dir = ""
|
|
278
|
+
self._xvfb_started_by_us = False # 是否由本实例独立启动的 Xvfb
|
|
279
|
+
self._vnc_used = False # 是否使用了 VNC 远程桌面的显示
|
|
83
280
|
|
|
84
281
|
def _get_user_data_dir(self) -> str:
|
|
85
282
|
"""获取用户数据目录路径"""
|
|
@@ -130,7 +327,27 @@ class StealthBrowser:
|
|
|
130
327
|
co.set_argument("--disable-dev-shm-usage")
|
|
131
328
|
co.set_argument("--disable-gpu")
|
|
132
329
|
|
|
133
|
-
#
|
|
330
|
+
# ── 无 DISPLAY 环境处理 ──
|
|
331
|
+
# 优先级: VNC远程桌面 > 自启动Xvfb > 降级headless
|
|
332
|
+
# 当 headless=False 但没有可用显示时:
|
|
333
|
+
# 1. 优先复用 VNC 远程桌面的 Xvfb 显示(用户可在 VNC 中看到浏览器操作)
|
|
334
|
+
# 2. VNC 未运行时自动启动 VNC(含 Xvfb + x11vnc + websockify)
|
|
335
|
+
# 3. VNC 启动失败则尝试独立启动 Xvfb
|
|
336
|
+
# 4. 都不可用则降级为 headless 模式
|
|
337
|
+
if not self._headless and not _has_display():
|
|
338
|
+
display = _ensure_display()
|
|
339
|
+
if display:
|
|
340
|
+
self._vnc_used = display.get("vnc", False)
|
|
341
|
+
self._xvfb_started_by_us = display.get("xvfb_standalone", False)
|
|
342
|
+
if self._vnc_used:
|
|
343
|
+
logger.info(f"复用 VNC 远程桌面显示 ({display['display']}),可在 VNC 中查看浏览器操作")
|
|
344
|
+
else:
|
|
345
|
+
self._headless = True
|
|
346
|
+
logger.warning(
|
|
347
|
+
"无 DISPLAY 环境且 VNC/Xvfb 均不可用,自动降级为 headless 模式"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# 无头模式(co.headless() 内部设置 --headless=new)
|
|
134
351
|
if self._headless:
|
|
135
352
|
co.headless()
|
|
136
353
|
|
|
@@ -138,13 +355,33 @@ class StealthBrowser:
|
|
|
138
355
|
browser_path = self._detect_browser()
|
|
139
356
|
if browser_path:
|
|
140
357
|
co.set_browser_path(browser_path)
|
|
358
|
+
else:
|
|
359
|
+
return SkillResult(
|
|
360
|
+
success=False,
|
|
361
|
+
error=(
|
|
362
|
+
"未找到 Chrome/Chromium 浏览器。"
|
|
363
|
+
"请安装 Chrome 或设置 CHROME_PATH 环境变量。"
|
|
364
|
+
),
|
|
365
|
+
)
|
|
141
366
|
|
|
142
367
|
# 设置窗口大小
|
|
143
368
|
co.set_argument("--window-size=1920,1080")
|
|
144
369
|
|
|
145
|
-
#
|
|
146
|
-
|
|
147
|
-
|
|
370
|
+
# 创建浏览器实例(增加重试逻辑)
|
|
371
|
+
last_error = None
|
|
372
|
+
for attempt in range(3):
|
|
373
|
+
try:
|
|
374
|
+
self._browser = Chromium(co)
|
|
375
|
+
self._page = self._browser.latest_tab
|
|
376
|
+
break
|
|
377
|
+
except Exception as e:
|
|
378
|
+
last_error = e
|
|
379
|
+
logger.warning(f"浏览器启动第 {attempt + 1}/3 次尝试失败: {e}")
|
|
380
|
+
# 清理可能残留的 Chrome 进程
|
|
381
|
+
self._kill_stale_chrome(user_data)
|
|
382
|
+
time.sleep(2 * (attempt + 1))
|
|
383
|
+
else:
|
|
384
|
+
raise last_error
|
|
148
385
|
|
|
149
386
|
# 如果浏览器已崩溃或无法获取 tab
|
|
150
387
|
if not self._page:
|
|
@@ -195,11 +432,27 @@ class StealthBrowser:
|
|
|
195
432
|
self._browser = None
|
|
196
433
|
self._page = None
|
|
197
434
|
logger.info("反检测浏览器已关闭")
|
|
198
|
-
return SkillResult(success=True, message="浏览器已关闭")
|
|
199
435
|
except Exception as e:
|
|
200
436
|
logger.error(f"关闭浏览器异常: {e}")
|
|
201
437
|
self._browser = None
|
|
202
438
|
self._page = None
|
|
439
|
+
|
|
440
|
+
# 如果由本实例独立启动了 Xvfb(非 VNC),关闭浏览器后停止虚拟显示
|
|
441
|
+
# 注意:VNC 管理自己的 Xvfb 生命周期,不在此停止
|
|
442
|
+
if self._xvfb_started_by_us:
|
|
443
|
+
self._xvfb_started_by_us = False
|
|
444
|
+
# 检查是否还有其他浏览器实例在使用 Xvfb
|
|
445
|
+
with _browser_lock:
|
|
446
|
+
other_active = any(
|
|
447
|
+
b._started and b is not self
|
|
448
|
+
for b in _browsers.values()
|
|
449
|
+
)
|
|
450
|
+
if not other_active:
|
|
451
|
+
_stop_xvfb()
|
|
452
|
+
|
|
453
|
+
# VNC 显示不由浏览器管理,无需停止
|
|
454
|
+
self._vnc_used = False
|
|
455
|
+
|
|
203
456
|
return SkillResult(success=True, message="浏览器已关闭")
|
|
204
457
|
|
|
205
458
|
async def navigate(self, url: str, wait: float = 2.0) -> SkillResult:
|
|
@@ -559,6 +812,34 @@ class StealthBrowser:
|
|
|
559
812
|
logger.debug(f"页面检查失败 (profile={self.profile_name}),浏览器可能已关闭")
|
|
560
813
|
return False
|
|
561
814
|
|
|
815
|
+
@staticmethod
|
|
816
|
+
def _kill_stale_chrome(user_data_dir: str = "") -> None:
|
|
817
|
+
"""清理可能残留的 Chrome/Chromium 进程"""
|
|
818
|
+
try:
|
|
819
|
+
# 查找使用相同 user-data-dir 的残留 Chrome 进程
|
|
820
|
+
patterns = [
|
|
821
|
+
"chromium-browser.*--remote-debugging-port",
|
|
822
|
+
"chromium.*--remote-debugging-port",
|
|
823
|
+
"chrome.*--remote-debugging-port",
|
|
824
|
+
"google-chrome.*--remote-debugging-port",
|
|
825
|
+
"headless_shell.*--remote-debugging-port",
|
|
826
|
+
]
|
|
827
|
+
for pattern in patterns:
|
|
828
|
+
result = subprocess.run(
|
|
829
|
+
["pgrep", "-f", pattern],
|
|
830
|
+
capture_output=True, text=True, timeout=5,
|
|
831
|
+
)
|
|
832
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
833
|
+
for pid_str in result.stdout.strip().split("\n"):
|
|
834
|
+
try:
|
|
835
|
+
pid = int(pid_str.strip())
|
|
836
|
+
os.kill(pid, 9) # SIGKILL
|
|
837
|
+
logger.info(f"已清理残留 Chrome 进程: PID {pid}")
|
|
838
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
839
|
+
pass
|
|
840
|
+
except Exception as e:
|
|
841
|
+
logger.debug(f"清理残留 Chrome 进程时出错: {e}")
|
|
842
|
+
|
|
562
843
|
@staticmethod
|
|
563
844
|
def _detect_browser() -> Optional[str]:
|
|
564
845
|
"""自动检测 Chromium/Chrome 浏览器路径"""
|
|
@@ -693,6 +974,12 @@ def close_stealth_browser(profile_name: str = "") -> None:
|
|
|
693
974
|
pass
|
|
694
975
|
_browsers.clear()
|
|
695
976
|
|
|
977
|
+
# 所有浏览器关闭后,仅在独立 Xvfb(非 VNC)时才停止
|
|
978
|
+
# VNC 管理自己的 Xvfb 生命周期
|
|
979
|
+
any_vnc = any(b._vnc_used for b in _browsers.values()) if _browsers else False
|
|
980
|
+
if not any_vnc:
|
|
981
|
+
_stop_xvfb()
|
|
982
|
+
|
|
696
983
|
|
|
697
984
|
async def close_stealth_browser_async(profile_name: str = "") -> None:
|
|
698
985
|
"""关闭浏览器实例(异步版本,供 async 上下文调用)"""
|
|
@@ -39,6 +39,189 @@ from aiskills.base import Skill, SkillResult, SkillParameter
|
|
|
39
39
|
|
|
40
40
|
logger = get_logger("myagent.skills.chromedev_mcp")
|
|
41
41
|
|
|
42
|
+
|
|
43
|
+
# ── Xvfb 虚拟显示管理 ──────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
_xvfb_process: Optional[subprocess.Popen] = None
|
|
46
|
+
_xvfb_display: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _has_display() -> bool:
|
|
50
|
+
"""检查当前环境是否有可用的 X11 显示"""
|
|
51
|
+
display = os.environ.get("DISPLAY", "").strip()
|
|
52
|
+
if not display:
|
|
53
|
+
return False
|
|
54
|
+
# DISPLAY 已设置,尝试检测是否真的可用
|
|
55
|
+
# 方法1: xdpyinfo
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["xdpyinfo"], capture_output=True, timeout=3,
|
|
59
|
+
env={**os.environ},
|
|
60
|
+
)
|
|
61
|
+
if result.returncode == 0:
|
|
62
|
+
return True
|
|
63
|
+
except Exception:
|
|
64
|
+
pass
|
|
65
|
+
# 方法2: 用 xprop/xdotool 等轻量工具验证
|
|
66
|
+
try:
|
|
67
|
+
for cmd in (["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"],
|
|
68
|
+
["xdotool", "getdisplaywidth"]):
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
cmd, capture_output=True, timeout=3,
|
|
71
|
+
env={**os.environ},
|
|
72
|
+
)
|
|
73
|
+
if result.returncode == 0:
|
|
74
|
+
return True
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
# 方法3: 检查 /tmp/.X11-unix/ 下的 socket 文件
|
|
78
|
+
try:
|
|
79
|
+
if display.startswith(":"):
|
|
80
|
+
num = display[1:].split(".")[0]
|
|
81
|
+
socket_path = f"/tmp/.X11-unix/X{num}"
|
|
82
|
+
if os.path.exists(socket_path):
|
|
83
|
+
return True
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _start_xvfb(display_num: int = 98) -> Optional[str]:
|
|
90
|
+
"""
|
|
91
|
+
启动 Xvfb 虚拟显示服务器。
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
DISPLAY 环境变量值(如 :98),失败返回 None
|
|
95
|
+
"""
|
|
96
|
+
global _xvfb_process, _xvfb_display
|
|
97
|
+
|
|
98
|
+
# 如果已经启动,直接返回
|
|
99
|
+
if _xvfb_process and _xvfb_process.poll() is None:
|
|
100
|
+
return _xvfb_display
|
|
101
|
+
|
|
102
|
+
# 清理旧的
|
|
103
|
+
if _xvfb_process:
|
|
104
|
+
try:
|
|
105
|
+
_xvfb_process.terminate()
|
|
106
|
+
_xvfb_process.wait(timeout=3)
|
|
107
|
+
except Exception:
|
|
108
|
+
try:
|
|
109
|
+
_xvfb_process.kill()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
_xvfb_process = None
|
|
113
|
+
|
|
114
|
+
xvfb_path = shutil.which("Xvfb")
|
|
115
|
+
if not xvfb_path:
|
|
116
|
+
logger.warning("Xvfb 未安装,无法启动虚拟显示")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
display_str = f":{display_num}"
|
|
120
|
+
try:
|
|
121
|
+
# 清理可能残留的 X11 锁文件
|
|
122
|
+
for lock_file in (f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"):
|
|
123
|
+
if os.path.exists(lock_file):
|
|
124
|
+
try:
|
|
125
|
+
os.unlink(lock_file)
|
|
126
|
+
except Exception:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
_xvfb_process = subprocess.Popen(
|
|
130
|
+
[xvfb_path, display_str, "-screen", "0", "1920x1080x24", "-ac", "-nolisten", "tcp"],
|
|
131
|
+
stdout=subprocess.DEVNULL,
|
|
132
|
+
stderr=subprocess.DEVNULL,
|
|
133
|
+
)
|
|
134
|
+
time.sleep(0.5)
|
|
135
|
+
if _xvfb_process.poll() is not None:
|
|
136
|
+
logger.error(f"Xvfb 启动后立即退出 (returncode={_xvfb_process.returncode})")
|
|
137
|
+
_xvfb_process = None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
_xvfb_display = display_str
|
|
141
|
+
os.environ["DISPLAY"] = display_str
|
|
142
|
+
logger.info(f"Xvfb 虚拟显示已启动 (chromedev_mcp): {display_str}")
|
|
143
|
+
return display_str
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"启动 Xvfb 失败: {e}")
|
|
146
|
+
_xvfb_process = None
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _stop_xvfb() -> None:
|
|
151
|
+
"""停止 Xvfb 虚拟显示服务器"""
|
|
152
|
+
global _xvfb_process, _xvfb_display
|
|
153
|
+
|
|
154
|
+
if _xvfb_process:
|
|
155
|
+
try:
|
|
156
|
+
_xvfb_process.terminate()
|
|
157
|
+
_xvfb_process.wait(timeout=5)
|
|
158
|
+
except Exception:
|
|
159
|
+
try:
|
|
160
|
+
_xvfb_process.kill()
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
_xvfb_process = None
|
|
164
|
+
_xvfb_display = None
|
|
165
|
+
logger.info("Xvfb 虚拟显示已停止 (chromedev_mcp)")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ensure_display() -> Optional[str]:
|
|
169
|
+
"""
|
|
170
|
+
确保有可用的 X11 显示,供浏览器有头模式使用。
|
|
171
|
+
|
|
172
|
+
优先级:
|
|
173
|
+
1. 复用 VNC 远程桌面的 Xvfb 显示(用户可在 VNC 中看到浏览器操作)
|
|
174
|
+
2. 自动启动 VNC(含 Xvfb + x11vnc + websockify)
|
|
175
|
+
3. VNC 不可用时独立启动 Xvfb
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
DISPLAY 值(如 ":99"),失败返回 None
|
|
179
|
+
"""
|
|
180
|
+
# 1. 尝试复用 VNC 远程桌面
|
|
181
|
+
try:
|
|
182
|
+
from core.vnc_manager import get_vnc_manager
|
|
183
|
+
vnc = get_vnc_manager()
|
|
184
|
+
|
|
185
|
+
# VNC 已在运行 → 直接复用其 DISPLAY
|
|
186
|
+
if vnc.is_running:
|
|
187
|
+
display = vnc.display # 默认 :99
|
|
188
|
+
os.environ["DISPLAY"] = display
|
|
189
|
+
logger.info(f"VNC 远程桌面已在运行,复用显示: {display}")
|
|
190
|
+
return display
|
|
191
|
+
|
|
192
|
+
# VNC 未运行 → 尝试自动启动
|
|
193
|
+
try:
|
|
194
|
+
import asyncio
|
|
195
|
+
try:
|
|
196
|
+
loop = asyncio.get_event_loop()
|
|
197
|
+
if loop.is_running():
|
|
198
|
+
import concurrent.futures
|
|
199
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
200
|
+
result = pool.submit(asyncio.run, vnc.start()).result(timeout=30)
|
|
201
|
+
else:
|
|
202
|
+
result = asyncio.run(vnc.start())
|
|
203
|
+
except RuntimeError:
|
|
204
|
+
result = asyncio.run(vnc.start())
|
|
205
|
+
|
|
206
|
+
if result.get("success"):
|
|
207
|
+
display = vnc.display
|
|
208
|
+
os.environ["DISPLAY"] = display
|
|
209
|
+
logger.info(f"VNC 远程桌面已自动启动,显示: {display}")
|
|
210
|
+
return display
|
|
211
|
+
else:
|
|
212
|
+
logger.warning(f"VNC 自动启动失败: {result.get('message', '')}")
|
|
213
|
+
except Exception as e:
|
|
214
|
+
logger.warning(f"VNC 自动启动异常: {e}")
|
|
215
|
+
|
|
216
|
+
except ImportError:
|
|
217
|
+
logger.debug("VNC 管理器不可用,跳过 VNC 集成")
|
|
218
|
+
except Exception as e:
|
|
219
|
+
logger.warning(f"VNC 检测异常: {e}")
|
|
220
|
+
|
|
221
|
+
# 2. VNC 不可用 → 独立启动 Xvfb
|
|
222
|
+
return _start_xvfb()
|
|
223
|
+
|
|
224
|
+
|
|
42
225
|
# ── MCP 通信常量 ──────────────────────────────────────────────
|
|
43
226
|
|
|
44
227
|
# chrome-devtools-mcp 支持的完整工具列表 (v0.21.0)
|
|
@@ -154,9 +337,23 @@ class MCPClient:
|
|
|
154
337
|
args.extend(["--chromeArg", "--no-sandbox", "--chromeArg", "--disable-setuid-sandbox"])
|
|
155
338
|
|
|
156
339
|
# [v1.17.0] 有头模式: 设置 DISPLAY 环境变量
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
340
|
+
# 优先级: 用户指定 DISPLAY > VNC 远程桌面 > 独立 Xvfb > 降级 headless
|
|
341
|
+
if not self._headless:
|
|
342
|
+
# 优先使用用户指定的 DISPLAY
|
|
343
|
+
if self._display_override:
|
|
344
|
+
env["DISPLAY"] = self._display_override
|
|
345
|
+
logger.info(f"有头模式: DISPLAY={self._display_override}")
|
|
346
|
+
# 无可用显示时,优先尝试 VNC 远程桌面,再尝试独立 Xvfb
|
|
347
|
+
elif not _has_display():
|
|
348
|
+
display = _ensure_display()
|
|
349
|
+
if display:
|
|
350
|
+
env["DISPLAY"] = display
|
|
351
|
+
logger.info(f"有头模式: DISPLAY={display}(通过 VNC/Xvfb 提供)")
|
|
352
|
+
else:
|
|
353
|
+
# 都不可用,降级为 headless 模式
|
|
354
|
+
self._headless = True
|
|
355
|
+
args.append("--headless")
|
|
356
|
+
logger.warning("无 DISPLAY 环境且 VNC/Xvfb 均不可用,自动降级为 headless 模式")
|
|
160
357
|
|
|
161
358
|
logger.info(f"启动 chrome-devtools-mcp: {' '.join(args)}")
|
|
162
359
|
|
|
@@ -629,6 +826,9 @@ class MCPClient:
|
|
|
629
826
|
# chrome-devtools-mcp 启动的 Chrome 可能不会随 MCP 进程一起退出
|
|
630
827
|
self._kill_stale_chrome()
|
|
631
828
|
|
|
829
|
+
# 清理 Xvfb 虚拟显示
|
|
830
|
+
_stop_xvfb()
|
|
831
|
+
|
|
632
832
|
def _kill_stale_chrome(self):
|
|
633
833
|
"""清理残留的 Chrome/Chromium 进程
|
|
634
834
|
|