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).
This commit is contained in:
2026-09-17 16:40:03 +08:00
parent 342e53df02
commit 554be4f92b
6 changed files with 445 additions and 6 deletions
+164
View File
@@ -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 ""
+8
View File
@@ -67,6 +67,14 @@ from PyQt6.QtCore import Qt
# 确保 Python 能找到项目根目录下的模块
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
# P1-03Chromium 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
+16 -3
View File
@@ -19,13 +19,26 @@ PyQt6-WebEngine-Qt6==6.10.2
# ---- 大模型 SDKOpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)----
openai==2.26.0
# ---- WebView2 绑定:pythonnetimport clr+ 其加载器 ----
pythonnet==3.1.0
clr_loader==0.3.1
# ---- WebView2 绑定:pythonnetimport clr+ 其加载器(仅 WindowsLinux 自动跳过)----
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 带平台 markerLinux 自动跳过
# 2) 安装 QtWebEngine 运行所需的系统库(apt,按需选装):
# sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 \
# libgl1 libegl1 libasound2t64 # 24.04 用 libasound2t6422.04 用 libasound2
# 离屏测试另需:QT_QPA_PLATFORM=offscreen
# 3) 运行:python3.10 main.py
# - 只使用 QtWebEngine;每实例独立 profiledata/webengine/profile_<pid>_*
# - root/容器启动若页面空白:显式 QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py
# (启动会打印高可见风险警告;普通桌面不要加)
# ===========================================================================
# 以下仅在需要时安装
# ===========================================================================
+242
View File
@@ -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 非 Windowswebview2_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 实例并行(独立 profileoffscreen
# ======================================================================
_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"<html><body>ok {{pid}}</body></html>")
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)
+14 -3
View File
@@ -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 默认 profilemain_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):
+1
View File
@@ -21,6 +21,7 @@ _BRIDGE_METHODS = (
"onDeleteMessageClicked",
"onAttachmentClicked",
"onScrollChanged",
"onRequestWindowPage", # 🆕 P1-01 渲染窗口换页请求
)