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).
165 lines
6.7 KiB
Python
165 lines
6.7 KiB
Python
# -*- 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 ""
|