Files
Haocode/core/renderer_backend.py
sorrow404null 554be4f92b 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).
2026-09-17 16:40:03 +08:00

165 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 ""