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:
@@ -0,0 +1,406 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
WebView2 后端(Windows 首选浏览器内核,失败自动回落 QtWebEngine)。
|
||||
|
||||
设计要点(来自 P1 实验 tests/_tmp_wv2_demo.py 的实测结论):
|
||||
1. pythonnet + WebView2 .NET SDK(vendor/webview2/ 内 net462 Core.dll + webview2loader_x64.dll)
|
||||
2. 必须 OleInitialize(STA)后才能 CreateAsync
|
||||
3. 本机的 SDK 怪癖:传任何非空 user_data_folder 都报 RuntimeNotFound → 一律用默认 profile(ud=None)
|
||||
4. 残留 msedgewebview2.exe 会锁默认 profile(0x800700AA)→ 初始化前 taskkill
|
||||
5. .NET 版 CoreWebView2 不暴露子窗口 HWND → EnumChildWindows 找 Chrome_WidgetWin_* 类
|
||||
6. 子窗口天然是 Qt 顶层窗口的子 HWND,不需要 QWindow.fromWinId 包装(对子窗口会失败),
|
||||
由 Qt 布局算 slot 矩形后用 SetBoundsAndZoomFactor 驱动(父窗客户区物理像素)
|
||||
7. JS→Python 用 WebMessageReceived(JSON),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 变 disposed(set_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) # STA(COM 初始化要求)
|
||||
# 清残留浏览器进程(锁默认 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-forget(ChatBridge.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
|
||||
Reference in New Issue
Block a user