From 554be4f92b31efe1d17c6d1af8a9d06e064abdd7 Mon Sep 17 00:00:00 2001 From: sorrow404null Date: Thu, 17 Sep 2026 16:40:03 +0800 Subject: [PATCH] feat(renderer): platform-gated dual renderer backend with isolated QtWebEngine profiles Windows prefers WebView2, Linux uses QtWebEngine only; pythonnet deps are win32-marked. Each QtWebEngine instance gets its own profile under data/webengine, and Chromium flags are sanitized before QApplication (--no-sandbox accepted only for explicit root/container use). --- core/renderer_backend.py | 164 +++++++++++++++++++++++ main.py | 8 ++ requirements.txt | 19 ++- tests/test_renderer_matrix.py | 242 ++++++++++++++++++++++++++++++++++ ui/views/custom_web_page.py | 17 ++- ui/views/wv2_view.py | 1 + 6 files changed, 445 insertions(+), 6 deletions(-) create mode 100644 core/renderer_backend.py create mode 100644 tests/test_renderer_matrix.py diff --git a/core/renderer_backend.py b/core/renderer_backend.py new file mode 100644 index 0000000..1635422 --- /dev/null +++ b/core/renderer_backend.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +"""P1-03:渲染器后端选择 + QtWebEngine profile/sandbox 适配(小模块,仅 stdlib,可在导入 PyQt6 前使用)。 + +契约见 docs/agent-handoff/PLATFORM_PLAN.md「浏览器后端」「Chromium sandbox」: + +- 平台矩阵:Windows 首选 WebView2、失败回落 QtWebEngine;非 Windows 只走 QtWebEngine + (非 Windows 不探测/导入/加载 WebView2 —— 由 main_window 的导入门控保证)。 +- ``webview_backend`` 配置值 ∈ {auto, webview2, qtwebengine};非法值 → 可见警告 + + 回落平台默认,绝不阻断源码启动。 +- 每个 QtWebEngine 实例使用独立 profile/storage 目录:源码运行阶段位于项目 + ``data/webengine/`` 范围;自动化测试用 ``HAOCODE_WEBENGINE_PROFILE_DIR`` 重定向到 + 临时目录。实例间不互相清理对方 profile。 +- Chromium sandbox 默认保留。仅当运行者显式在 ``QTWEBENGINE_CHROMIUM_FLAGS`` 中加入 + ``--no-sandbox`` 且当前确认为 root/容器场景时才接受(打印高可见警告); + 普通桌面运行中的 ``--no-sandbox`` 会被剥离并告警。 +""" +import os +import sys +import time + +#: webview_backend 合法配置值 +VALID_BACKENDS = ("auto", "webview2", "qtwebengine") + +_NO_SANDBOX = "--no-sandbox" + + +def is_windows() -> bool: + return sys.platform == "win32" + + +def platform_default_backend() -> str: + """平台默认渲染器:Windows → webview2;其余 → qtwebengine。""" + return "webview2" if is_windows() else "qtwebengine" + + +def resolve_backend(pref) -> "tuple[str, str | None]": + """归一化 ``webview_backend`` 配置(不抛异常)。 + + 返回 ``(backend, warning)``:backend ∈ {"webview2", "qtwebengine"}; + warning 为 None 或需要可见打印的警告(非法值 / 平台不支持)。 + """ + default = platform_default_backend() + if not isinstance(pref, str): + return default, (f"[Renderer] ⚠️ webview_backend 配置非法: {pref!r}" + f"(应为字符串 auto/webview2/qtwebengine)→ 回落平台默认 {default}") + p = pref.strip().lower() + if p not in VALID_BACKENDS: + return default, (f"[Renderer] ⚠️ webview_backend 配置非法: {pref!r}" + f"(允许 {('/'.join(VALID_BACKENDS))})→ 回落平台默认 {default}") + if p == "auto": + return default, None + if p == "webview2" and not is_windows(): + return "qtwebengine", (f"[Renderer] ⚠️ webview_backend=webview2 但当前平台为 {sys.platform}" + f"(WebView2 仅 Windows 支持)→ 回落 qtwebengine") + return p, None + + +def webview2_module(): + """按需加载 core.webview2:非 Windows 永不导入(不触达 pythonnet/Win32/DLL/taskkill)。 + + main_window 在模块级用 `sys.platform == "win32"` 门控导入;本函数供其他入口复用同一门控。 + """ + if not is_windows(): + return None + try: + from core import webview2 + except Exception as e: + print(f"[WV2] 后端不可用(回落 QtWebEngine): {e}") + return None + return webview2 + + +def webengine_profile_name() -> str: + """当前实例的 QtWebEngine profile 名(进程 pid + 启动毫秒时间戳,天然唯一)。 + + 两个并行源码实例 → 两个不同 profile 名 → 两个独立 Chromium profile 目录,互不争用。 + """ + return f"profile_{os.getpid()}_{int(time.time() * 1000)}" + + +def webengine_profile_dir(profile_name: str | None = None) -> str: + """当前实例的独立 profile/storage 目录(源码运行在 data/webengine/;测试经环境变量重定向)。 + + 返回目录已确保存在。本函数【不删除】任何已有目录(实例间不互相清理)。 + """ + name = profile_name or webengine_profile_name() + base = os.environ.get("HAOCODE_WEBENGINE_PROFILE_DIR", "").strip() + if not base: + base = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "webengine") + d = os.path.join(base, name) + try: + os.makedirs(d, exist_ok=True) + except Exception: + pass + return d + + +# ---------------------------------------------------------------------- +# Chromium sandbox 契约 +# ---------------------------------------------------------------------- + +_CONTAINER_MARKERS = ("/.dockerenv", "/run/.containerenv", "/.lxc") +_CGROUP_HINTS = ("docker", "containerd", "kubepods", "lxc") + + +def is_root_or_container() -> bool: + """root/容器场景探测(仅 POSIX 有意义;Windows 恒 False)。""" + if os.name != "posix": + return False + try: + if os.geteuid() == 0: + return True + except Exception: + pass + for marker in _CONTAINER_MARKERS: + try: + if os.path.exists(marker): + return True + except Exception: + pass + try: + with open("/proc/1/cgroup", "r", encoding="utf-8", errors="replace") as f: + cgroup = f.read() + if any(k in cgroup for k in _CGROUP_HINTS): + return True + except Exception: + pass + return False + + +def sanitize_chromium_flags(flags: str, verbose: bool = True) -> str: + """按契约处理 ``QTWEBENGINE_CHROMIUM_FLAGS`` 中的 ``--no-sandbox``(main.py 在导入 PyQt6 前调用)。 + + - 显式带 ``--no-sandbox``: + - root/容器场景 → 保留 + 高可见风险提示; + - 普通桌面 → 剥离并告警(普通运行不得关闭 sandbox)。 + - 未带 ``--no-sandbox``: + - root/容器场景 → 提示可能需显式设置; + - 其余原样返回。 + """ + def _say(msg: str) -> None: + if verbose: + print(msg, flush=True) + + tokens = (flags or "").split() + has_ns = _NO_SANDBOX in tokens + rootc = is_root_or_container() + if has_ns: + if rootc: + _say("[Renderer] ⚠️⚠️ 高可见警告:已启用 --no-sandbox(显式配置 + 确认为 root/容器场景)。" + "Chromium 沙箱已关闭,攻击面显著增大;仅限 root/容器调试环境," + "普通桌面运行请勿使用。") + return flags + stripped = " ".join(t for t in tokens if t != _NO_SANDBOX) + _say("[Renderer] ⚠️ QTWEBENGINE_CHROMIUM_FLAGS 含 --no-sandbox,但当前是普通桌面运行" + "(非 root/非容器)→ 已剥离该标志并保留 Chromium 沙箱。" + "如确属 root/容器环境,请检查运行环境识别是否正确。") + return stripped + if rootc: + _say("[Renderer] ⚠️ 检测到 root/容器环境:Chromium 沙箱可能无法启动。" + "若页面空白,请显式在 QTWEBENGINE_CHROMIUM_FLAGS 中加入 --no-sandbox" + "(显式选择,启动时会打印风险警告)。") + return flags or "" diff --git a/main.py b/main.py index 521408e..afa3fb6 100644 --- a/main.py +++ b/main.py @@ -67,6 +67,14 @@ from PyQt6.QtCore import Qt # 确保 Python 能找到项目根目录下的模块 sys.path.append(os.path.dirname(os.path.abspath(__file__))) +# P1-03:Chromium sandbox 契约(在导入 PyQt6/QtWebEngine 前处理;renderer_backend 仅 stdlib) +# - 默认保留 sandbox;--no-sandbox 仅在显式配置 + root/容器场景下接受(高可见警告) +# - 普通桌面运行中的 --no-sandbox 被剥离并告警 +from core import renderer_backend as _renderer_backend +os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = _renderer_backend.sanitize_chromium_flags( + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"]) +print(f"[渲染] 最终 QTWEBENGINE_CHROMIUM_FLAGS = {os.environ['QTWEBENGINE_CHROMIUM_FLAGS']!r}", flush=True) + # 导入主窗口类 from ui.views.main_window import MainWindow diff --git a/requirements.txt b/requirements.txt index 7cafecd..9929022 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,13 +19,26 @@ PyQt6-WebEngine-Qt6==6.10.2 # ---- 大模型 SDK(OpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)---- openai==2.26.0 -# ---- WebView2 绑定:pythonnet(import clr)+ 其加载器 ---- -pythonnet==3.1.0 -clr_loader==0.3.1 +# ---- WebView2 绑定:pythonnet(import clr)+ 其加载器(仅 Windows;Linux 自动跳过)---- +pythonnet==3.1.0; sys_platform == "win32" +clr_loader==0.3.1; sys_platform == "win32" # ---- PDF 附件解析(ui/views/main_window.py → tools/builtin_tools/pdf_reader.py)---- PyMuPDF==1.28.0 +# =========================================================================== +# Linux 源码运行(Ubuntu 22.04/24.04 x64,仅 QtWebEngine;不装 WebView2 任何东西) +# =========================================================================== +# 1) pip install -r requirements.txt # pythonnet/clr_loader 带平台 marker,Linux 自动跳过 +# 2) 安装 QtWebEngine 运行所需的系统库(apt,按需选装): +# sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 \ +# libgl1 libegl1 libasound2t64 # 24.04 用 libasound2t64;22.04 用 libasound2 +# 离屏测试另需:QT_QPA_PLATFORM=offscreen +# 3) 运行:python3.10 main.py +# - 只使用 QtWebEngine;每实例独立 profile(data/webengine/profile__*) +# - root/容器启动若页面空白:显式 QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py +# (启动会打印高可见风险警告;普通桌面不要加) + # =========================================================================== # 以下仅在需要时安装 # =========================================================================== diff --git a/tests/test_renderer_matrix.py b/tests/test_renderer_matrix.py new file mode 100644 index 0000000..bfe5221 --- /dev/null +++ b/tests/test_renderer_matrix.py @@ -0,0 +1,242 @@ +# -*- coding: utf-8 -*- +""" +P1-03 渲染器矩阵单测(平台模拟 + 真实进程级,无 UI 依赖,不启动真实 LLM) + +覆盖: + R1 resolve_backend 平台矩阵:Windows/Linux × auto/webview2/qtwebengine/非法值 + (非法值 → 可见警告 + 平台默认;Linux 请求 webview2 → 警告 + qtwebengine) + R3 Linux 模拟导入链(子进程 sys.platform='linux' 导入 ui.views.main_window): + _wv2mod 为 None,且 core.webview2 / clr / clr_loader 未进入 sys.modules + R4 两个 QtWebEngine 实例并行(2 个 offscreen 子进程同时跑): + 各自独立 profile 载入本地页面并关闭,无 profile 锁争用、无互相清理 + R5 Chromium sandbox 标志契约:普通桌面剥离 --no-sandbox + 告警; + root/容器 + 显式 --no-sandbox → 保留 + 高可见警告;root/容器未设 → 提示 + +运行: PYTHONIOENCODING=utf-8 python tests/test_renderer_matrix.py +""" +import contextlib +import io +import os +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PY = sys.executable +IS_WIN = os.name == "nt" + +from core import renderer_backend as rb # noqa: E402 + +RESULTS = [] + + +def check(name, cond, extra=""): + RESULTS.append((name, bool(cond))) + print(f"{'PASS' if cond else 'FAIL'} {name} {extra if not cond else ''}", flush=True) + + +# ====================================================================== +# R1. resolve_backend 平台矩阵(mock 平台,纯逻辑) +# ====================================================================== +_orig_is_win = rb.is_windows + + +def _mock(is_win: bool): + rb.is_windows = lambda: is_win + + +try: + _mock(True) + b, w = rb.resolve_backend("auto") + check("R1.1 Win auto → webview2 无警告", b == "webview2" and w is None, f"{b} {w!r}") + b, w = rb.resolve_backend("webview2") + check("R1.2 Win webview2 → webview2", b == "webview2" and w is None, f"{b} {w!r}") + b, w = rb.resolve_backend("qtwebengine") + check("R1.3 Win qtwebengine → qtwebengine(强制回落)", b == "qtwebengine" and w is None, + f"{b} {w!r}") + b, w = rb.resolve_backend("gecko") + check("R1.4 Win 非法值 → webview2 + 可见警告", + b == "webview2" and w and "非法" in w, f"{b} {w!r}") + b, w = rb.resolve_backend(123) + check("R1.5 Win 非字符串 → webview2 + 警告", b == "webview2" and w is not None, + f"{b} {w!r}") + b, w = rb.resolve_backend(" QTWebEngine ") + check("R1.6 大小写/空白容差 → qtwebengine", b == "qtwebengine" and w is None, + f"{b} {w!r}") + + _mock(False) + b, w = rb.resolve_backend("auto") + check("R1.7 Linux auto → qtwebengine 无警告", b == "qtwebengine" and w is None, + f"{b} {w!r}") + b, w = rb.resolve_backend("webview2") + check("R1.8 Linux webview2 → qtwebengine + 警告", + b == "qtwebengine" and w and "webview2" in w, f"{b} {w!r}") + b, w = rb.resolve_backend("qtwebengine") + check("R1.9 Linux qtwebengine → qtwebengine", b == "qtwebengine" and w is None, + f"{b} {w!r}") + b, w = rb.resolve_backend("gecko") + check("R1.10 Linux 非法值 → qtwebengine + 警告", + b == "qtwebengine" and w and "非法" in w, f"{b} {w!r}") +finally: + rb.is_windows = _orig_is_win + +# webview2_module 门控(同进程 mock) +try: + _mock(False) + check("R1.11 非 Windows:webview2_module() 返回 None 且不导入", + rb.webview2_module() is None) +finally: + rb.is_windows = _orig_is_win + +# ====================================================================== +# R5. Chromium sandbox 标志契约(mock root/容器探测) +# ====================================================================== +_orig_rootc = rb.is_root_or_container + + +def _run_sanitize(flags, rootc): + rb.is_root_or_container = lambda: rootc + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + out = rb.sanitize_chromium_flags(flags, verbose=True) + return out, buf.getvalue() + + +try: + out, log = _run_sanitize("--disable-gpu", False) + check("R5.1 普通桌面:无 --no-sandbox 原样返回、无告警", + out == "--disable-gpu" and "no-sandbox" not in log, f"{out!r} {log!r}") + + out, log = _run_sanitize("--disable-gpu --no-sandbox", False) + check("R5.2 普通桌面:剥离 --no-sandbox + 告警", + out == "--disable-gpu" and "--no-sandbox" in log, f"{out!r} {log!r}") + + out, log = _run_sanitize("--disable-gpu --no-sandbox", True) + check("R5.3 root/容器 + 显式:保留 --no-sandbox + 高可见警告", + out == "--disable-gpu --no-sandbox" and "高可见警告" in log, f"{out!r} {log!r}") + + out, log = _run_sanitize("--disable-gpu", True) + check("R5.4 root/容器未设:原样返回 + 提示显式设置", + out == "--disable-gpu" and "--no-sandbox" in log and "高可见警告" not in log, + f"{out!r} {log!r}") +finally: + rb.is_root_or_container = _orig_rootc + +# ====================================================================== +# R3. Linux 模拟导入链(子进程:sys.platform='linux' 后 import main_window) +# ====================================================================== +r3_code = ( + "import os, sys;" + f"sys.path.insert(0, {ROOT!r});" + "os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen');" + "os.environ['QTWEBENGINE_CHROMIUM_FLAGS'] = '--disable-gpu';" + "sys.platform = 'linux';" # 模拟非 Windows(导入门控按 sys.platform 判断) + "import ui.views.main_window as m;" + "bad = sorted(k for k in ('core.webview2', 'clr', 'clr_loader') if k in sys.modules);" + "ok = m._wv2mod is None and not bad;" + "print('R3_OK' if ok else f'R3_FAIL {bad!r}')" +) +r3 = subprocess.run([PY, "-c", r3_code], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=180) +check("R3.1 Linux 模拟:main_window 导入成功且 _wv2mod 为 None、未导入 webview2/clr", + "R3_OK" in (r3.stdout or ""), + f"rc={r3.returncode} out={(r3.stdout or '')[-200:]!r} err={(r3.stderr or '')[-300:]!r}") + +# ====================================================================== +# R4. 两个 QtWebEngine 实例并行(独立 profile,offscreen) +# ====================================================================== +_r4_base = tempfile.mkdtemp(prefix="haocode_p103_profiles_") +_worker = os.path.join(_r4_base, "qtwe_worker.py") +_worker_code = f'''# -*- coding: utf-8 -*- +import os, sys +sys.path.insert(0, {ROOT!r}) +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import QTimer, QUrl +from PyQt6.QtWebEngineCore import QWebEngineProfile +from PyQt6.QtWebEngineWidgets import QWebEngineView +from ui.views.custom_web_page import CustomWebPage +from core import renderer_backend as rbr + +app = QApplication(sys.argv) +pid = os.getpid() +name = f"parallel_{{pid}}" +pdir = rbr.webengine_profile_dir(name) +prof = QWebEngineProfile(name) +prof.setPersistentStoragePath(os.path.join(pdir, "storage")) +prof.setCachePath(os.path.join(pdir, "cache")) + +html = os.path.join(os.path.dirname(pdir), f"page_{{pid}}.html") +with open(html, "w", encoding="utf-8") as f: + f.write(f"ok {{pid}}") + +view = QWebEngineView() +view.setPage(CustomWebPage(prof, view)) +view.resize(800, 600) +view.show() + +st = {{"ok": False}} +fired = {{"v": False}} + +def finish(): + if fired["v"]: return + fired["v"] = True + print(f"WORKER_OK pid={{pid}} loaded={{st['ok']}} profile={{pdir}}", flush=True) + app.quit() + +view.page().loadFinished.connect(lambda o: (st.__setitem__("ok", bool(o)), finish())) +view.load(QUrl.fromLocalFile(html)) + +QTimer.singleShot(30000, finish) # 兜底显式时间预算(30s) +app.exec() +sys.exit(0 if st["ok"] else 1) +''' +with open(_worker, "w", encoding="utf-8") as f: + f.write(_worker_code) + +_r4_env = {**os.environ, + "QT_QPA_PLATFORM": "offscreen", + "QTWEBENGINE_CHROMIUM_FLAGS": "--disable-gpu", + "HAOCODE_WEBENGINE_PROFILE_DIR": _r4_base, + "PYTHONIOENCODING": "utf-8"} + +procs = [] +try: + for _ in range(2): + procs.append(subprocess.Popen([PY, _worker], stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, + encoding="utf-8", errors="replace", env=_r4_env)) + outs = [] + for p in procs: + try: + out, err = p.communicate(timeout=90) + outs.append((p.returncode, out, err)) + except subprocess.TimeoutExpired: + p.kill() + outs.append((None, "", "TIMEOUT")) + oks = [rc == 0 and "WORKER_OK" in out for rc, out, err in outs] + check("R4.1 两个 QtWebEngine 实例并行:都成功载入本地页面", + all(oks) and len({out for _, out, _ in outs if out}) == 2, + repr([(rc, (out or '')[-120:], (err or '')[-200:]) for rc, out, err in outs])) + # 两个不同 profile 目录、关闭后目录仍在(无互相清理) + prof_dirs = [] + for rc, out, err in outs: + for line in (out or "").splitlines(): + if line.startswith("WORKER_OK"): + prof_dirs.append(line.rsplit("profile=", 1)[1].strip()) + check("R4.2 两个实例 profile 目录不同(独立,不争用)", + len(prof_dirs) == 2 and prof_dirs[0] != prof_dirs[1], repr(prof_dirs)) + check("R4.3 关闭后 profile 目录仍存在(无互相清理)", + all(os.path.isdir(d) for d in prof_dirs), repr(prof_dirs)) +finally: + for p in procs: + try: + if p.poll() is None: + p.kill() + except Exception: + pass + +# ====================================================================== +print(f"\n===== {sum(1 for _, ok in RESULTS if ok)}/{len(RESULTS)} passed =====", flush=True) +sys.exit(0 if all(ok for _, ok in RESULTS) else 1) diff --git a/ui/views/custom_web_page.py b/ui/views/custom_web_page.py index 781d16b..0571d37 100644 --- a/ui/views/custom_web_page.py +++ b/ui/views/custom_web_page.py @@ -1,4 +1,4 @@ -from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript +from PyQt6.QtWebEngineCore import QWebEnginePage, QWebEngineScript, QWebEngineProfile from PyQt6.QtCore import QUrl from PyQt6.QtGui import QDesktopServices @@ -10,8 +10,19 @@ class CustomWebPage(QWebEnginePage): 2. 禁用 Ctrl/Meta + 滚轮 及 Ctrl + +/-/0 快捷键的页面缩放 (浏览器式缩放对桌面聊天工具无意义且易误触)。 """ - def __init__(self, parent=None): - super().__init__(parent) + def __init__(self, profile_or_parent=None, parent=None): + # P1-03:兼容两种调用 —— 新式 CustomWebPage(profile, parent) 与旧式 CustomWebPage(parent)。 + # profile 缺省 None → QtWebEngine 默认 profile;main_window 传入本实例独立 profile。 + if isinstance(profile_or_parent, QWebEngineProfile): + profile, par = profile_or_parent, parent + else: + profile, par = None, profile_or_parent + if parent is not None: + raise TypeError("CustomWebPage(profile, parent) 或 CustomWebPage(parent)") + if profile is not None: + super().__init__(profile, par) + else: + super().__init__(par) self._inject_zoom_lock() def _inject_zoom_lock(self): diff --git a/ui/views/wv2_view.py b/ui/views/wv2_view.py index 53b81c6..edb646b 100644 --- a/ui/views/wv2_view.py +++ b/ui/views/wv2_view.py @@ -21,6 +21,7 @@ _BRIDGE_METHODS = ( "onDeleteMessageClicked", "onAttachmentClicked", "onScrollChanged", + "onRequestWindowPage", # 🆕 P1-01 渲染窗口换页请求 )