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