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:
@@ -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 ""
|
||||
@@ -67,6 +67,14 @@ from PyQt6.QtCore import Qt
|
||||
# 确保 Python 能找到项目根目录下的模块
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# P1-03:Chromium 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
@@ -19,13 +19,26 @@ PyQt6-WebEngine-Qt6==6.10.2
|
||||
# ---- 大模型 SDK(OpenAI 兼容接口;DeepSeek / opencode-go / 自建反代均走它)----
|
||||
openai==2.26.0
|
||||
|
||||
# ---- WebView2 绑定:pythonnet(import clr)+ 其加载器 ----
|
||||
pythonnet==3.1.0
|
||||
clr_loader==0.3.1
|
||||
# ---- WebView2 绑定:pythonnet(import clr)+ 其加载器(仅 Windows;Linux 自动跳过)----
|
||||
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 带平台 marker,Linux 自动跳过
|
||||
# 2) 安装 QtWebEngine 运行所需的系统库(apt,按需选装):
|
||||
# sudo apt install -y libnss3 libxkbcommon0 libfontconfig1 libdbus-1-3 \
|
||||
# libgl1 libegl1 libasound2t64 # 24.04 用 libasound2t64;22.04 用 libasound2
|
||||
# 离屏测试另需:QT_QPA_PLATFORM=offscreen
|
||||
# 3) 运行:python3.10 main.py
|
||||
# - 只使用 QtWebEngine;每实例独立 profile(data/webengine/profile_<pid>_*)
|
||||
# - root/容器启动若页面空白:显式 QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox" python3.10 main.py
|
||||
# (启动会打印高可见风险警告;普通桌面不要加)
|
||||
|
||||
# ===========================================================================
|
||||
# 以下仅在需要时安装
|
||||
# ===========================================================================
|
||||
|
||||
@@ -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 非 Windows:webview2_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 实例并行(独立 profile,offscreen)
|
||||
# ======================================================================
|
||||
_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)
|
||||
@@ -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 默认 profile;main_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):
|
||||
|
||||
@@ -21,6 +21,7 @@ _BRIDGE_METHODS = (
|
||||
"onDeleteMessageClicked",
|
||||
"onAttachmentClicked",
|
||||
"onScrollChanged",
|
||||
"onRequestWindowPage", # 🆕 P1-01 渲染窗口换页请求
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user