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 ""