chore: import original project baseline

Import the pre-repair source tree as the history baseline.
Runtime data (data/), virtualenvs, bytecode caches and logs are
gitignored so local secrets and user state stay out of the repo.
This commit is contained in:
2026-09-17 16:40:01 +08:00
commit a7412824e0
124 changed files with 26747 additions and 0 deletions
+406
View File
@@ -0,0 +1,406 @@
# -*- coding: utf-8 -*-
"""
WebView2 后端(Windows 首选浏览器内核,失败自动回落 QtWebEngine)。
设计要点(来自 P1 实验 tests/_tmp_wv2_demo.py 的实测结论):
1. pythonnet + WebView2 .NET SDKvendor/webview2/ 内 net462 Core.dll + webview2loader_x64.dll
2. 必须 OleInitializeSTA)后才能 CreateAsync
3. 本机的 SDK 怪癖:传任何非空 user_data_folder 都报 RuntimeNotFound → 一律用默认 profileud=None
4. 残留 msedgewebview2.exe 会锁默认 profile0x800700AA)→ 初始化前 taskkill
5. .NET 版 CoreWebView2 不暴露子窗口 HWND → EnumChildWindows 找 Chrome_WidgetWin_* 类
6. 子窗口天然是 Qt 顶层窗口的子 HWND,不需要 QWindow.fromWinId 包装(对子窗口会失败),
由 Qt 布局算 slot 矩形后用 SetBoundsAndZoomFactor 驱动(父窗客户区物理像素)
7. JS→Python 用 WebMessageReceivedJSON),Python→JS 用 ExecuteScriptAsync(与现有
ChatBridge.run_js 生成的 JS 调用文本完全同构,前端零改动;仅 index.html 的
window.bridge bootstrap 走双协议)
"""
import os
import sys
import time
import shutil
import subprocess
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WV2_DIR = os.path.join(ROOT, "vendor", "webview2")
CORE_DLL = os.path.join(WV2_DIR, "net462_Microsoft.Web.WebView2.Core.dll")
LOADER = os.path.join(WV2_DIR, "webview2loader_x64.dll")
_env = None
_System = None # pythonnet 加载后缓存
_INSTANCE_LOCK = {"fh": None}
def _instance_lock_path():
# 可覆盖(测试隔离用:HAOCODE_INSTANCE_LOCK_FILE 指向临时文件,
# 否则测试会与正在运行的 app 争同一把锁 → 断言依赖环境)
p = os.environ.get("HAOCODE_INSTANCE_LOCK_FILE")
if p:
return p
d = os.path.join(ROOT, "data")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return os.path.join(d, "app_instance.lock")
def acquire_instance_lock():
"""独占 data/app_instance.lock(非阻塞)。
返回 True = 本机唯一实例 → 可安全 taskkill 残留 msedgewebview2.exe
False = 已有实例在跑 → 必须回落 QtWebEngine
None = 平台不支持/异常(保守当“不确定”处理)
🐛 T0 根因:本函数存在之前,每个 MainWindow()(包括 offscreen 测试)都会走到
_get_environment() 里的 `taskkill /F /IM msedgewebview2.exe`
把【当时正在运行的生产 app】的 WebView2 浏览器进程一并杀掉
→ 它的 controller 变 disposedset_bounds 报 0x8007139F
→ DOM 照渲染但视觉层永久空白(= “选中会话不渲染核心内容”)。
"""
if sys.platform != "win32":
return None
if _INSTANCE_LOCK["fh"] is not None:
return True
fh = None
try:
import msvcrt
fh = open(_instance_lock_path(), "a+b")
fh.seek(0) # 固定锁位置(锁定前 1 字节),不依赖 append 模式的当前位置
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
_INSTANCE_LOCK["fh"] = fh
return True
except Exception:
try:
if fh is not None:
fh.close()
except Exception:
pass
return False
def _wv2_allowed_here() -> bool:
"""无头/测试环境一律不得碰 WebView2(共享默认 profile,一碰就可能误杀在生产实例)"""
if os.environ.get("HAOCODE_FORCE_QTWEBENGINE", "") in ("1", "true", "True"):
return False
plat = (os.environ.get("QT_QPA_PLATFORM") or "").strip().lower()
if plat and plat != "windows":
return False
return True
def _pump_wait(op, app, timeout=15.0):
"""在 Qt 事件循环里等 .NET Task 完成(WebView2 初始化依赖消息泵)"""
t0 = time.time()
while not op.IsCompleted and time.time() - t0 < timeout:
app.processEvents()
time.sleep(0.005)
if not op.IsCompleted:
raise TimeoutError("WebView2 operation timed out")
if op.IsFaulted:
raise RuntimeError(str(op.Exception))
return op.Result
def _ts():
"""启动链时间戳(WebView2 冷启动耗时诊断,保留)"""
t = time.time()
return time.strftime("%H:%M:%S", time.localtime(t)) + f".{int(t*1000) % 1000:03d}"
def get_environment(app):
"""初始化并返回 CoreWebView2Environment 单例;任何失败返回 None(调用方回落 QtWebEngine"""
global _env
if _env is not None:
return _env
if sys.platform != "win32":
return None
if not (os.path.exists(CORE_DLL) and os.path.exists(LOADER)):
return None
# 🛡 守卫 1:无头/测试环境(QT_QPA_PLATFORM=offscreen 等)绝不启用 WebView2
if not _wv2_allowed_here():
print(f"[WV2] QT_QPA_PLATFORM={os.environ.get('QT_QPA_PLATFORM')!r} → 跳过 WebView2"
f"回落 QtWebEngine(无头环境不得触碰共享 profile)")
return None
# 🛡 守卫 2:本机已有实例在跑 → 不启用 WebView2、更不 taskkill(否则会把它的
# 浏览器进程杀掉 → 对方 controller disposed → 聊天区永久空白)
try:
_lock_ok = acquire_instance_lock()
except Exception:
_lock_ok = None
if _lock_ok is not True:
print("[WV2] 检测到已有 haocode 实例在运行(instance lock 被占)→ "
"本实例回落 QtWebEngine;已跳过 taskkill,不会影响对方渲染")
return None
try:
os.environ["PATH"] = WV2_DIR + ";" + os.environ.get("PATH", "")
try:
os.add_dll_directory(WV2_DIR)
except Exception:
pass
# .NET Core 的 P/Invoke 默认不查 CWD → 把 loader 复制到 CWD 一份
try:
cwd_loader = os.path.join(os.getcwd(), "WebView2Loader.dll")
if not os.path.exists(cwd_loader):
shutil.copyfile(LOADER, cwd_loader)
except Exception:
pass
import ctypes
ctypes.windll.ole32.OleInitialize(None) # STACOM 初始化要求)
# 清残留浏览器进程(锁默认 profile 会导致 0x800700AA
# 🛡 只有【本机唯一实例】才会走到这里(已在上面用 instance lock 保证),
# 否则会把兄弟实例的浏览器进程杀掉 → 对方控制器 disposed → 聊天区空白
print(f"[WV2] {_ts()} warmup: taskkill 残留进程(唯一实例,安全)...")
try:
subprocess.run(["taskkill", "/F", "/IM", "msedgewebview2.exe"],
capture_output=True, timeout=10)
time.sleep(1.0)
except Exception:
pass
import clr
global _System
clr.AddReference(CORE_DLL)
import System
_System = System
from Microsoft.Web.WebView2.Core import CoreWebView2Environment
# ⚠️ ud=None:本机 SDK 对非空 user_data 路径报 RuntimeNotFound(实测怪癖)
_env = _pump_wait(CoreWebView2Environment.CreateAsync(None, None, None), app)
print(f"[WV2] {_ts()} Runtime ready: {_env.BrowserVersionString}")
return _env
except Exception as ex:
_env = None
print(f"[WV2] init failed → fallback to QtWebEngine: {ex}")
return None
class Wv2Session:
"""一个 WebView2 实例:controller + 子窗口 + 消息泵 + bounds 驱动"""
def __init__(self, env, parent_hwnd, app):
self.app = app
print(f"[WV2] {_ts()} controller create begin (hwnd={parent_hwnd:#x})")
self.controller = _pump_wait(
env.CreateCoreWebView2ControllerAsync(_System.IntPtr(int(parent_hwnd))), app)
print(f"[WV2] {_ts()} controller ready")
self.core = self.controller.CoreWebView2
try:
self.core.Settings.AreDefaultContextMenusEnabled = False
self.core.Settings.IsZoomControlEnabled = False
self.core.Settings.IsStatusBarEnabled = False
except Exception:
pass
self.child_hwnd = 0
self.on_message = None # callable(dict) —— main_window 绑定到 ChatBridge
self.on_load_finished = None # callable() —— WebView2View 绑定 loadFinished 信号
self._msg_handler = self._make_msg_handler()
self.core.add_WebMessageReceived(self._msg_handler)
self._nw_handler = self._make_navigated_handler()
self.core.add_NavigationCompleted(self._nw_handler)
# 🆕 真异步 JS 执行队列:单一定时器轮询完成,回调从主线程定时器发出。
# (旧版 execute_js_async 是阻塞忙等 + processEvents → 从定时器/事件回调里调用
# 时产生重入嵌套事件循环 → COM 事件分发崩溃 → 启动卡死/拖动时 webview 不刷新)
self._js_pending = [] # [task, cb, t0]
from PyQt6.QtCore import QTimer
self._js_pump = QTimer()
self._js_pump.setInterval(25)
self._js_pump.timeout.connect(self._js_pump_tick)
self._js_pump.start()
# 🆕 预热:立即导航 about:blank,让 msedgewebview2 进程/GPU 在 UI 构建期间冷启动
# (实测本机首次真实页面导航需 12-15s,预热后降到 ~1s)
try:
print(f"[WV2] {_ts()} warmup: Navigate about:blank")
self.core.Navigate("about:blank")
except Exception as ex:
print("[WV2] warmup navigate error:", ex)
# ---------- 事件 ----------
def _make_msg_handler(self):
import json
from System import EventHandler
from Microsoft.Web.WebView2.Core import CoreWebView2WebMessageReceivedEventArgs
def handler(sender, args):
try:
data = json.loads(args.WebMessageAsJson)
if self.on_message:
self.on_message(data)
except Exception as ex:
print("[WV2] message error:", ex)
return EventHandler[CoreWebView2WebMessageReceivedEventArgs](handler)
def _make_navigated_handler(self):
from System import EventHandler
from Microsoft.Web.WebView2.Core import CoreWebView2NavigationCompletedEventArgs
def handler(sender, args):
try:
src = self.core.Source or ""
except Exception:
src = "?"
print(f"[WV2] {_ts()} NavigationCompleted src={src}")
# 预热页(about:blank)的加载完成不触发 loadFinished(避免 JS 探针空转)
if src.startswith("about:blank"):
return
if self.on_load_finished:
self.on_load_finished()
return EventHandler[CoreWebView2NavigationCompletedEventArgs](handler)
# ---------- 子窗口发现(.NET 不暴露 HWND,轮询枚举) ----------
def find_child_once(self):
"""单次探测;找到返回 hwnd,否则 0(非阻塞,供 UI 线程定时器调)"""
import ctypes
import ctypes.wintypes as wt
u32 = ctypes.windll.user32
parent = wt.HWND(self.controller.ParentWindow.ToInt64())
found = []
@ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID)
def cb(h, _):
buf = ctypes.create_unicode_buffer(256)
u32.GetClassNameW(h, buf, 256)
if buf.value.startswith("Chrome_WidgetWin"):
found.append(h)
return True
u32.EnumChildWindows(parent, cb, None)
if found:
self.child_hwnd = int(found[0])
return self.child_hwnd
return 0
def find_child(self):
"""同步等待并返回 Chrome_WidgetWin_* 子 HWND(最多 10s"""
import ctypes
import ctypes.wintypes as wt
u32 = ctypes.windll.user32
parent = wt.HWND(self.controller.ParentWindow.ToInt64())
for _ in range(200):
found = []
@ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPVOID)
def cb(h, _):
buf = ctypes.create_unicode_buffer(256)
u32.GetClassNameW(h, buf, 256)
if buf.value.startswith("Chrome_WidgetWin"):
found.append(h)
return True
u32.EnumChildWindows(parent, cb, None)
if found:
self.child_hwnd = int(found[0])
return self.child_hwnd
self.app.processEvents()
time.sleep(0.05)
return 0
# ---------- 几何 ----------
def set_visible(self, visible: bool):
try:
self.controller.IsVisible = bool(visible)
except Exception:
pass
def set_bounds(self, left, top, width, height):
"""父窗客户区物理像素"""
try:
from System.Drawing import Rectangle as _Rect
self.controller.SetBoundsAndZoomFactor(_Rect(int(left), int(top),
int(width), int(height)), 1.0)
except Exception as ex:
print("[WV2] set_bounds error:", ex)
def child_size(self):
"""子窗口当前屏幕像素尺寸(观察用)"""
if not self.child_hwnd:
return (0, 0)
import ctypes
import ctypes.wintypes as wt
r = wt.RECT()
if ctypes.windll.user32.GetWindowRect(wt.HWND(self.child_hwnd), ctypes.byref(r)):
return (r.right - r.left, r.bottom - r.top)
return (0, 0)
# ---------- JS ----------
def navigate(self, url: str):
print(f"[WV2] {_ts()} navigate {url[:80]}")
try:
self.core.Navigate(url)
except Exception as ex:
print("[WV2] navigate error:", ex)
def execute_js(self, script: str):
"""fire-and-forgetChatBridge.run_js 的替换,JS 文本完全同构)"""
self._js_run(script, None)
def execute_js_async(self, script: str, cb):
"""带回调执行:真异步,回调在主线程定时器 tick 中发出(绝不阻塞)"""
self._js_run(script, cb)
def _js_run(self, script: str, cb):
try:
task = self.core.ExecuteScriptWithResultAsync(script)
except Exception as ex:
print("[WV2] execute_js error:", ex)
if cb:
try:
cb(None)
except Exception:
pass
return
self._js_pending.append([task, cb, time.time()])
def _js_pump_tick(self):
if not self._js_pending:
return
import json
remaining = []
for task, cb, t0 in self._js_pending:
done = False
try:
done = task.IsCompleted
except Exception:
done = True
if not done and time.time() - t0 > 10:
done = True # 10s 安全超时(渲染器死亡时不永久卡队列)
if not done:
remaining.append([task, cb, t0])
continue
result = None
try:
if task.IsCompleted and not task.IsFaulted:
r = task.Result # CoreWebView2ExecuteScriptResult 包装结构
if getattr(r, "Succeeded", True):
s = r.ResultAsJson # JSON 编码字符串(或 null
if s:
result = json.loads(s)
if isinstance(result, str):
try:
result = json.loads(result)
except Exception:
pass
except Exception as ex:
print("[WV2] js result decode error:", ex)
if cb:
try:
cb(result)
except Exception as ex:
print("[WV2] js callback error:", ex)
self._js_pending = remaining
def close(self):
try:
self._js_pump.stop()
except Exception:
pass
self._js_pending = []
try:
self.core.remove_WebMessageReceived(self._msg_handler)
except Exception:
pass
try:
self.core.remove_NavigationCompleted(self._nw_handler)
except Exception:
pass
try:
self.controller.Close()
except Exception:
pass