From c6587d88ee608422a0d116d7ddc747b254880e02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=81=B5=E5=AE=9D=E4=BB=99=E5=B0=8A?= Date: Thu, 5 Nov 2026 17:22:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=99=A8=EF=BC=9A=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=89=93=E5=8C=85=E5=90=8E=E8=87=AA=E6=88=91=E5=A5=97?= =?UTF-8?q?=E5=A8=83=E5=BC=80=E7=AA=97=EF=BC=8C=E8=A1=A5=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=B8=8E=E6=8E=92=E6=9F=A5=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:start_gmweb() 用 sys.executable 拉起 gmweb/serve.py,源码运行时是 python.exe,PyInstaller 单文件打包后却是启动器自身 —— 于是 `exe serve.py` 又开了一个启动器,新启动器再去拉 serve.py,每代顺延一个端口并各开一个 空窗口,直到 19546 端口用尽。开发机因为 18112 早被占用走了 early return, 所以看不出来。 - 新增多调用模式 run_script_instead():exe [args] 时用 runpy 直接 执行该脚本,不开窗口/不提权/不占互斥量,打包后拉起 serve.py 才合法 - 新增单实例互斥(Local\DNF90Launcher-5E9C1B7A):重复启动把已有窗口 ShowWindow+SetForegroundWindow 后退出,不再开第二个窗口 - start_gmweb() 增加就绪校验:spawn 后最多等 6 秒确认 18112 在监听,未监听 则改用 importlib 在本进程线程里跑 serve.py 的 main(),绝不再拉起新窗口 - self_elevate() 提权重启带上原始参数(原来 ShellExecuteW 传 None,参数全丢); DNF90_GMWEB_CHILD=1 的子进程不再触发 UAC - WebView2 数据目录固定到 %LOCALAPPDATA%\DNF90Launcher\WebView2 且 private_mode=False,避免 exe 在只读目录/网络盘时窗口空白且无报错; 启动前查注册表确认运行时是否存在 - 新增 启动器日志.txt(exe 同目录,512KB 转 .old.txt):console=False 下 print 与未捕获异常全部落盘,顶部记录 pid/argv/SERVER_ROOT - 文档:新增 docs/启动器窗口风暴排查.md(现象、根因、复现手法、修复点、 日志判读表),README 常见坑与文档索引、docs/README、launcher/README 同步 --- .gitignore | 5 + README.md | 3 + docs/README.md | 1 + docs/启动器窗口风暴排查.md | 126 +++++++++++++++ launcher/README.md | 10 ++ launcher/server.py | 321 ++++++++++++++++++++++++++++++++++--- 6 files changed, 442 insertions(+), 24 deletions(-) create mode 100644 docs/启动器窗口风暴排查.md diff --git a/.gitignore b/.gitignore index 2c55fcc..af15c7c 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ launcher/dnf_items.json *.swo .DS_Store Thumbs.db + +# 启动器运行日志(exe 同目录) +launcher/启动器日志.txt +启动器日志.txt +启动器日志.old.txt diff --git a/README.md b/README.md index 6acb1db..79897c2 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,8 @@ pvfexport -pvf Script.pvf -out dnf_items.json -compact | 关于页底部被日志栏盖住 | 内容高度超过可视区 | GM 页 `#body` 的 `bottom` 必须等于 `#log` 实高(含 padding/border) | | 客户端连不上服务端 | DLL 与 EXE 指纹不匹配 | 用配套的一对重新打包,别混用 | | 竖线不显示 | CSS 特异性:`.cols .box` 把 border 清掉了 | 分隔线写在 `.cols .box-r` / `.cols .box-m` 上 | +| 启动器窗口越开越多、里面没内容 | 打包后 `sys.executable` 就是启动器自己,`start_gmweb()` 拉起的是新窗口而不是 `serve.py` | 已有「多调用模式 + 单实例互斥 + 就绪校验」,见 [docs/启动器窗口风暴排查.md](docs/启动器窗口风暴排查.md) | +| 启动器窗口能开但一片空白 | WebView2 数据目录不可写(exe 在只读目录/网络盘) | 已固定到 `%LOCALAPPDATA%\DNF90Launcher\WebView2`;缺运行时看 `启动器日志.txt` | ## 十一、文档索引 @@ -191,6 +193,7 @@ pvfexport -pvf Script.pvf -out dnf_items.json -compact | [docs/服务端开发文档.md](docs/服务端开发文档.md) | 服务端架构、GM 接口契约、拍卖行与背包改动 | | [docs/PVF解析开发文档.md](docs/PVF解析开发文档.md) | pvfine 与 pvfexport、离线名称集成 | | [docs/客户端补丁与CRC校验.md](docs/客户端补丁与CRC校验.md) | 90CN.dll 自校验 CRC、`patch_dll_crc.py` 用法、与 DLL 指纹的区别 | +| [docs/启动器窗口风暴排查.md](docs/启动器窗口风暴排查.md) | 启动器套娃开窗的根因与修复、日志判读、现场取证 | | `server/README.md`、`pvf/README.md`、`launcher/README.md`、`gmtool/README.md` | 各子项目说明 | | [docs/release/](docs/release/) | 发布说明模板、网盘下载 `.url` 模板 | diff --git a/docs/README.md b/docs/README.md index 4427c08..cb73b81 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ | [服务端开发文档.md](服务端开发文档.md) | **服务端(Go)** 架构、GM 接口契约、拍卖行/背包改动、编译部署 | | [PVF解析开发文档.md](PVF解析开发文档.md) | **PVF 解析** 套件(pvfine)、pvfexport 工具、离线名称显示集成 | | [客户端补丁与CRC校验.md](客户端补丁与CRC校验.md) | **客户端补丁(90CN.dll)** 自校验机制、`patch_dll_crc.py` 用法、与「服务端 DLL 指纹」的区别、排查手册 | +| [启动器窗口风暴排查.md](启动器窗口风暴排查.md) | **启动器重复开窗口 / 页面空白** 的根因(打包后 `sys.executable` 指向自身)、复现手法、修复点与日志判读 | | [launcher-README.md](launcher-README.md) | 整合启动器 + GM 工具的原始 README | 其余子项目的 README 分散在各自目录: diff --git a/docs/启动器窗口风暴排查.md b/docs/启动器窗口风暴排查.md new file mode 100644 index 0000000..0b6554f --- /dev/null +++ b/docs/启动器窗口风暴排查.md @@ -0,0 +1,126 @@ +# 启动器「窗口越开越多、里面一片空白」排查记录 + +> 版本:v2026.11.05 之后修复(启动器 15,945,162 字节) +> 现象只在部分电脑出现,开发机复现不了;本文记录根因、复现手法、修复点与现场取证方法。 + +## 一、现象 + +- 双击启动器后,屏幕上每隔一两秒冒出一个新的启动器窗口,都是黑底、没有界面内容 +- 内存、CPU 一路涨,任务管理器里 `DNF90启动器.exe` 一片 +- 游戏内嵌的 GM 页(`127.0.0.1:18112`)打不开 / 空白 +- 开机第一次启动更容易出现;已经有一种「别的东西占着 18112」的电脑反而看不到 + +## 二、根因:启动器把自己当成了 python.exe + +`launcher/server.py` 的 `start_gmweb()` 要拉起 GM 页服务,写法是: + +```python +subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)]) +# ^^^^^^^^^^^^^^ 源码运行时是 python.exe,打包后是启动器自己 +``` + +源码运行时 `sys.executable` 是 `python.exe`,`python.exe serve.py --port 18112` 正常。 +用 PyInstaller 打成 **单文件** 后,`sys.executable` 变成 `DNF90启动器.exe`,于是实际执行的是: + +``` +DNF90启动器.exe Z:\90server\gmweb\serve.py --port 18112 +``` + +单文件 bootloader 不认这种「拿参数当脚本」的用法,它只会**再跑一遍内嵌的 server.py**,而 server.py 又没有 `sys.argv` 分支处理,于是: + +1. 新进程重新解包 16MB 的单文件包 → 绑定下一个空闲端口(19528) +2. 它也会调用 `start_gmweb()`,发现 18112 还是空的(因为 serve.py 从来没被真正执行过) +3. 于是**又拉起一个自己** +4. 每一代在拉完下一代的瞬间创建自己的窗口 + +链条是这样的(每代约 1.5 秒,端口从 19527 顺延到 19546): + +``` +exe(19527) ──spawn──> exe(19528) ──spawn──> exe(19529) ──> … 直到端口用尽 + │ │ │ + 开窗口 开窗口 开窗口 ← 全是没有内容的新窗口 +``` + +端口到 19546 用完后 `ThreadingHTTPServer` 绑定失败,`console=False` + 窗口程序会弹一个 traceback 对话框,所以有些机器上还会看到报错弹窗。 + +## 三、为什么开发机复现不了 + +`start_gmweb()` 的第一句是: + +```python +if port_open(GM_WEB_PORT): + print("GM 页服务已在运行") + return +``` + +开发机上很早就有一个 `pythonw.exe serve.py`(或 `gmweb\start_gmweb.bat` 起的)占着 18112,所以这段直接 return,永远走不到那句错误的 spawn。实测把那个进程结束后,**6 秒内就堆出 3 个实例、端口涨到 19531,而 18112 始终没有监听**。 + +结论:只要一台机器满足「`gmweb\serve.py` 存在」+「18112 空闲」,就一定会套娃;反之会被掩盖。这就是「个别电脑」的来源。 + +## 四、复现与验证手法 + +```bash +# 1. 先确认 18112 是空的(否则会被 return 掩盖) +netstat -ano | findstr 127.0.0.1:18112 + +# 2. 直接跑打包后的 exe,观察端口是否一路顺延 +DNF90启动器.exe & +sleep 6; netstat -ano | findstr 127.0.0.1:195 + +# 3. 反向验证「exe 会不会执行传进去的 py」 +printf 'open(r"C:\\temp\\hit.txt","w").write("ok")' > probe.py +DNF90启动器.exe probe.py # 旧版:hit.txt 不生成,却开出一个窗口 +``` + +修复后第 3 步会真的执行 `probe.py`(生成 `hit.txt`)并且**不开窗口**。 + +## 五、修复内容 + +| 改动 | 说明 | +|------|------| +| **多调用模式** `run_script_instead()` | `exe [args]` 时用 `runpy.run_path` 直接执行该脚本,不开窗口、不提权、不占互斥量。打包后 `sys.executable` 拉起 `serve.py` 从此是合法的 | +| **单实例互斥** `acquire_single_instance()` | 命名互斥量 `Local\DNF90Launcher-5E9C1B7A`;重复启动时把已有窗口 `ShowWindow(SW_RESTORE)` + `SetForegroundWindow` 后退出,不再开第二个窗口 | +| **就绪校验 + 进程内兜底** | spawn 后最多等 6 秒确认 18112 真的在监听;没监听就改用 `importlib` 在本进程线程里跑 `serve.py` 的 `main()`,**任何情况下都不会再拉起一个新窗口** | +| **提权带参** `self_elevate()` | 原来 `ShellExecuteW(..., None, ...)` 提权时把参数丢了,重启出来的进程是「干干净净」的启动器;现在用 `list2cmdline(sys.argv[1:])` 原样带上,并且 gmweb 子进程(`DNF90_GMWEB_CHILD=1`)不再提权 | +| **WebView2 数据目录** | 显式指定 `%LOCALAPPDATA%\DNF90Launcher\WebView2` 并 `private_mode=False`。默认位置放在只读目录 / 网络盘 / 映射盘时建不起来,窗口能出但一片空白,且不报错 | +| **运行日志** `启动器日志.txt` | exe 同目录,超过 512KB 自动转 `.old.txt`;`console=False` 下 print 和未捕获异常全部落盘,现场才有据可查 | + +### 关于「没内容」的两层含义 + +1. 套娃出来的窗口是**每代新进程**,它们连的是各自顺延的端口,页面还在加载就被下一代挤爆,观感就是黑框; +2. 真正属于「内容出不来」的是 WebView2 数据目录的问题(只读目录/网络盘),已按上表固定到 `LOCALAPPDATA`。 + +## 六、现场取证 + +让用户把 **exe 同目录的 `启动器日志.txt`** 发回来即可,正常启动长这样: + +``` +[2026-11-05 17:20:38] ===== 启动 pid=24412 +[2026-11-05 17:20:38] exe=Z:\90server\DNF90启动器.exe +[2026-11-05 17:20:38] argv=['Z:\\90server\\DNF90启动器.exe'] +[2026-11-05 17:20:38] SERVER_ROOT=Z:\90server +[2026-11-05 17:20:38] 启动器端口:19527 +[2026-11-05 17:20:38] gmweb:已拉起子进程 pid=20260 +[2026-11-05 17:20:39] ===== 启动 pid=20260 +[2026-11-05 17:20:39] argv=['Z:\\90server\\DNF90启动器.exe', 'Z:\\90server\\gmweb\\serve.py', '--port', '18112'] +[2026-11-05 17:20:39] 多调用模式:执行 Z:\90server\gmweb\serve.py ['--port', '18112'] +[2026-11-05 17:20:39] gmweb:http://127.0.0.1:18112/ 就绪 +[2026-11-05 17:20:41] WebView2 数据目录:C:\Users\...\AppData\Local\DNF90Launcher\WebView2 +[2026-11-05 17:20:41] WebView2 运行时:135.0.3179.73 +``` + +判读要点: + +| 日志特征 | 结论 | +|----------|------| +| 出现多条 `===== 启动` 且 pid 各不相同,但**没有**「多调用模式」 | 又在套娃(旧版 exe,或 `serve.py` 参数被吞) | +| `已有启动器在运行,唤起原窗口后退出` | 单实例生效,属正常 | +| `gmweb:子进程未监听,改为进程内启动` | 子进程起不来,已自动兜底;看上一行 `子进程启动失败` 的原因 | +| `警告:注册表里没查到 WebView2 运行时` | 机器缺 Edge WebView2 Runtime,装一下即可(窗口会是空白) | +| 完全没有日志文件 | exe 所在目录不可写(如 Program Files),换目录或给写权限 | + +## 七、相关坑(顺带记录) + +- **exe 名字带中文**没问题,日志和 WebView2 目录都能正常处理;但 exe 放在**只读目录**会让日志与 WebView2 同时失效,表现就是「能开窗口但空白、也没有日志」。 +- `_kill_webview2_children()` 用的是 `wmic`,Windows 11 24H2 起 `wmic` 默认已被移除、部分安全策略也会拦它;这段现在是 `try/except` 静默失败,不影响主流程,但别再依赖它清理残留进程。 +- 启动器单文件每次启动都要解包约 16MB;因此「重复启动」的代价不只是多一个窗口,还会多解包一次。单实例互斥顺带把这件事省了。 diff --git a/launcher/README.md b/launcher/README.md index f072429..13df82e 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -18,6 +18,16 @@ | `index.html` / `style.css` / `app.js` | 双视图前端 | | `DNF90启动器.spec` | PyInstaller 打包配置 | | `sponsor.png` / `sponsor_qr.png` | 赞助二维码 | +| `qr/` | 关于页二维码(alipay / wechat / gzh / qq,可被 exe 同目录的同名文件覆盖) | +| `启动器日志.txt` | 运行日志(运行时生成,exe 同目录,超过 512KB 转 `.old.txt`) | + +## 运行时约定 + +- **单实例**:命名互斥量 `Local\DNF90Launcher-5E9C1B7A`,重复启动只会把已有窗口提到前台 +- **多调用模式**:`DNF90启动器.exe [args]` 会用 `runpy` 直接执行该脚本。打包后 `sys.executable` 就是 exe 自己,`start_gmweb()` 正是靠这条路径拉起 `gmweb/serve.py` 的 +- **提权**:UAC 自提权重启会带上原始参数;`DNF90_GMWEB_CHILD=1` 的子进程不再提权 +- **WebView2 数据目录**:固定在 `%LOCALAPPDATA%\DNF90Launcher\WebView2`,避免 exe 在只读目录/网络盘时窗口空白 +- 排障与现场取证见 [`docs/启动器窗口风暴排查.md`](../docs/启动器窗口风暴排查.md) ## 打包 diff --git a/launcher/server.py b/launcher/server.py index c7ad4f1..98f0a32 100644 --- a/launcher/server.py +++ b/launcher/server.py @@ -21,6 +21,7 @@ import shutil import socket import subprocess import sys +import tempfile import threading import time import uuid @@ -192,18 +193,197 @@ def port_open(port, host="127.0.0.1"): s.close() +# ------------------------------------------------------------------ 日志 +LOG_FILE_NAME = "启动器日志.txt" +LOG_MAX_BYTES = 512 * 1024 +_log_stream = None + + +class _TeeStream: + """把 print / 异常输出同时写进日志文件。 + + 打包成 console=False 的窗口程序后 sys.stdout 是 None,出问题时用户 + 什么也看不到;这里留一份日志,个别电脑上的怪问题才有据可查。 + """ + + def __init__(self, *streams): + self._streams = [x for x in streams if x is not None] + + def write(self, data): + for x in self._streams: + try: + x.write(data) + x.flush() + except Exception: + pass + return len(data) + + def flush(self): + for x in self._streams: + try: + x.flush() + except Exception: + pass + + def isatty(self): + return False + + def fileno(self): + # 没有真实文件描述符;第三方库探测到 OSError 会自行降级 + raise OSError("no fileno") + + @property + def encoding(self): + return "utf-8" + + +def _logf(msg): + """带时间戳写一行日志(源码运行时同时打到控制台)。 + + 名字不能叫 log —— 文件后半段 GM 流程日志也叫 log,会把这个覆盖掉。 + """ + line = "[%s] %s" % (time.strftime("%Y-%m-%d %H:%M:%S"), msg) + st = _log_stream + if st is not None: + # 已经装过 tee:直接写文件,print 会经 tee 再写一遍变成双份 + try: + st.write(line + "\n") + st.flush() + except Exception: + pass + return + try: + print(line) + except Exception: + pass + + +def _install_file_log(): + """冻结运行时把 stdout/stderr 落盘到 exe 同目录,方便回收现场。""" + global _log_stream + if not getattr(sys, "frozen", False): + return + path = os.path.join(BASE_DIR, LOG_FILE_NAME) + try: + if os.path.exists(path) and os.path.getsize(path) > LOG_MAX_BYTES: + try: + os.replace(path, path[:-4] + ".old.txt") + except OSError: + pass + st = open(path, "a", encoding="utf-8", errors="replace") + except Exception: + return + _log_stream = st + sys.stdout = _TeeStream(sys.stdout, st) + sys.stderr = _TeeStream(sys.stderr, st) + + def _hook(etype, value, tb): + import traceback + _logf("未捕获异常:\n" + "".join(traceback.format_exception(etype, value, tb))) + + sys.excepthook = _hook + _logf("===== 启动 pid=%s" % os.getpid()) + _logf("exe=%s" % sys.executable) + _logf("argv=%r" % (sys.argv,)) + _logf("SERVER_ROOT=%s" % SERVER_ROOT) + + +# ------------------------------------------------------------------ 单实例 +INSTANCE_MUTEX = "Local\\DNF90Launcher-5E9C1B7A" +WINDOW_TITLE = "DNF90 灵宝小竹单机版" +_mutex_handle = None + + +def acquire_single_instance(): + """同一会话只允许一个启动器。 + + 没有这道闸,双击几次就开出几个窗口;更糟的是每个实例都会去拉 gmweb, + 端口被抢着顺延,看起来就像「窗口越开越多」。 + """ + global _mutex_handle + if os.name != "nt": + return True + try: + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + k32.CreateMutexW.argtypes = [ctypes.c_void_p, ctypes.c_bool, ctypes.c_wchar_p] + k32.CreateMutexW.restype = ctypes.c_void_p + handle = k32.CreateMutexW(None, False, INSTANCE_MUTEX) + if not handle: + return True # 创建失败不阻断启动 + if ctypes.get_last_error() == 183: # ERROR_ALREADY_EXISTS + k32.CloseHandle(handle) + return False + _mutex_handle = handle + return True + except Exception: + return True + + +def focus_existing_window(wait_seconds=6.0): + """把已经在跑的那个窗口提到前台(新实例的窗口可能还在解包)。""" + try: + u32 = ctypes.WinDLL("user32", use_last_error=True) + u32.FindWindowW.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p] + u32.FindWindowW.restype = ctypes.c_void_p + deadline = time.time() + wait_seconds + while time.time() < deadline: + hwnd = u32.FindWindowW(None, WINDOW_TITLE) + if hwnd: + u32.ShowWindow(ctypes.c_void_p(hwnd), 9) # SW_RESTORE + u32.SetForegroundWindow(ctypes.c_void_p(hwnd)) + return True + time.sleep(0.3) + except Exception: + pass + return False + + +def run_script_instead(): + """多调用模式:本 exe 带 参数启动时,直接执行那个脚本。 + + 打包成单文件后 sys.executable 就是启动器自身,拉起 gmweb/serve.py + 只能走这条路 —— 旧版没做这件事,结果 `exe serve.py` 又开了一个启动器, + 新启动器又去拉 serve.py …… 一路开出十几个空窗口。 + """ + if len(sys.argv) < 2: + return False + target = sys.argv[1] + if not target.lower().endswith(".py"): + return False + path = target if os.path.isabs(target) else os.path.abspath(target) + if not os.path.exists(path): + return False + sys.argv = [path] + sys.argv[2:] + sys.path.insert(0, os.path.dirname(path)) + _logf("多调用模式:执行 %s %r" % (path, sys.argv[1:])) + try: + import runpy + runpy.run_path(path, run_name="__main__") + except SystemExit: + pass + except BaseException as exc: # noqa: BLE001 + _logf("多调用模式执行失败:%r" % exc) + return True + + def start_gmweb(): """拉起本地 GM 页服务(127.0.0.1:18112),供游戏内嵌网页窗口显示。 页面与代理脚本在 <90server>/gmweb/,它把 /api/* 转发到 admin 口并注入 token, - 这样 IE 内核不用处理跨域,页面里也不会出现明文 token。失败只提示,不阻断启动。 + 这样 IE 内核不用处理跨域,页面里也不会出现明文 token。 + + 子进程用 sys.executable 启动:源码运行时是 python.exe,打包后则是启动器 + 自身,靠 run_script_instead() 的多调用模式去执行 serve.py。子进程起来后 + 必须确认端口真的在监听——没监听就退回进程内启动,绝不再拉起一个新窗口。 """ script = os.path.join(SERVER_ROOT, "gmweb", "serve.py") if not os.path.exists(script): + _logf("gmweb:未找到 %s,跳过" % script) return if port_open(GM_WEB_PORT): - print("GM 页服务已在运行:http://127.0.0.1:%d/" % GM_WEB_PORT) + _logf("gmweb:%d 已在监听,跳过启动" % GM_WEB_PORT) return + try: kwargs = {"cwd": os.path.dirname(script), "stdout": subprocess.DEVNULL, @@ -211,10 +391,42 @@ def start_gmweb(): if os.name == "nt": # CREATE_NO_WINDOW:不要给用户弹一个黑框 kwargs["creationflags"] = 0x08000000 - subprocess.Popen([sys.executable, script, "--port", str(GM_WEB_PORT)], **kwargs) - print("GM 页服务:http://127.0.0.1:%d/" % GM_WEB_PORT) + env = os.environ.copy() + env["DNF90_GMWEB_CHILD"] = "1" # 标记:这是执行 serve.py 的子进程 + kwargs["env"] = env + proc = subprocess.Popen( + [sys.executable, script, "--port", str(GM_WEB_PORT)], **kwargs) + _logf("gmweb:已拉起子进程 pid=%s" % proc.pid) except Exception as exc: # noqa: BLE001 - 启动器不能因此退出 - print("[提示] GM 页服务启动失败:%s" % exc) + _logf("gmweb:子进程启动失败:%r" % exc) + + for _ in range(30): # 最多等 6 秒(本机被拒是立刻返回的) + if port_open(GM_WEB_PORT): + _logf("gmweb:http://127.0.0.1:%d/ 就绪" % GM_WEB_PORT) + return + time.sleep(0.2) + + _logf("gmweb:子进程未监听,改为进程内启动") + start_gmweb_inproc(script) + + +def start_gmweb_inproc(script): + """兜底:在本进程里起一条线程跑 serve.py 的 main()(不新增窗口)。""" + + def worker(): + try: + import importlib.util + spec = importlib.util.spec_from_file_location("dnf90_gmweb_serve", script) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + _logf("gmweb:进程内启动,脚本已加载") + mod.main(["--port", str(GM_WEB_PORT)]) + except SystemExit: + pass + except BaseException as exc: # noqa: BLE001 + _logf("gmweb:进程内启动失败:%r" % exc) + + threading.Thread(target=worker, daemon=True, name="gmweb-inproc").start() def _read_json_file(path, default): @@ -2238,32 +2450,49 @@ def _apply_loadout_scheme(scheme): # ------------------------------------------------------------------ 自提权 def self_elevate(): - """非管理员时通过 UAC 自提权重启自身。""" + """非管理员时通过 UAC 自提权重启自身。 + + 注意两点: + 1. gmweb 子进程(serve.py)不需要管理员,直接跳过,避免又弹一次 UAC; + 2. 提权重启必须把原始参数带上,否则子进程会丢掉 `serve.py` 这类参数, + 变成一个「干干净净」的新启动器窗口。 + """ + if os.environ.get("DNF90_GMWEB_CHILD") == "1": + return if is_admin() or os.environ.get("DNF90_NO_ELEVATE") == "1": return + exe = sys.executable if getattr(sys, "frozen", False): - exe = sys.executable - try: - rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, None, None, 1) - if rc > 32: - sys.exit(0) - except Exception: - pass + params = subprocess.list2cmdline(sys.argv[1:]) else: - exe = sys.executable - args = '"%s" "%s"' % (exe, os.path.abspath(__file__)) - try: - rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", exe, args, None, 1) - if rc > 32: - sys.exit(0) - except Exception: - pass + params = subprocess.list2cmdline([os.path.abspath(__file__)] + sys.argv[1:]) + _logf("尝试 UAC 自提权:%s %s" % (exe, params)) + try: + rc = ctypes.windll.shell32.ShellExecuteW( + None, "runas", exe, params or None, None, 1) + if rc > 32: + sys.exit(0) + _logf("自提权被拒或失败:rc=%s(继续以当前权限运行)" % rc) + except Exception as exc: # noqa: BLE001 + _logf("自提权异常:%r" % exc) # ------------------------------------------------------------------ 主入口 def main(): + _install_file_log() + + # 子进程模式:exe 带着某个 .py 启动 → 只执行脚本,不开窗口、不提权、不占互斥量 + if run_script_instead(): + return + self_elevate() + # 单实例:第二个实例把已有窗口唤到前台后退出,避免窗口越开越多 + if not acquire_single_instance(): + _logf("已有启动器在运行,唤起原窗口后退出") + focus_existing_window() + return + if not os.path.exists(CONTROL): print("[警告] 未找到服务端控制器,请将启动器放在 90server 根目录旁。") print(" 期望路径:%s" % CONTROL) @@ -2277,6 +2506,7 @@ def main(): port = DEFAULT_PORT while port_open(port) and port < DEFAULT_PORT + 20: port += 1 + _logf("启动器端口:%d" % port) server = ThreadingHTTPServer(("127.0.0.1", port), Handler) url = "http://127.0.0.1:%d/" % port @@ -2443,6 +2673,27 @@ class WindowApi: print("[窗口] 移动失败:%s" % e) +def webview2_installed(): + """注册表里查 WebView2 运行时版本(查不到就返回 False)。""" + try: + import winreg + sub = (r"SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\\" + r"{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}") + for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + for path in (sub, sub.replace("WOW6432Node\\", "")): + try: + with winreg.OpenKey(root, path) as k: + ver, _ = winreg.QueryValueEx(k, "pv") + if ver and ver != "0.0.0.0": + _logf("WebView2 运行时:%s" % ver) + return True + except OSError: + continue + except Exception: + pass + return False + + def show_window(url): """用 pywebview 内嵌窗口显示界面(独立原生窗口,而非浏览器)。 @@ -2470,7 +2721,7 @@ def show_window(url): # 主窗口:启动器(无边框,屏幕居中) window = webview.create_window( - "DNF90 灵宝小竹单机版", + WINDOW_TITLE, url, width=1204, height=717, @@ -2483,9 +2734,31 @@ def show_window(url): js_api=launcher_api, ) - webview.start() + # WebView2 的用户数据目录必须可写。exe 放在只读目录、网络盘或某些 + # 映射盘下时,默认位置建不起来 → 窗口能出来但里面一片空白, + # 而且不会有任何报错。统一放到 LOCALAPPDATA 下最稳。 + storage = None + try: + storage = os.path.join( + os.environ.get("LOCALAPPDATA") or tempfile.gettempdir(), + "DNF90Launcher", "WebView2") + os.makedirs(storage, exist_ok=True) + except Exception as exc: # noqa: BLE001 + _logf("WebView2 数据目录创建失败:%r" % exc) + storage = None + _logf("WebView2 数据目录:%s" % storage) + + if not webview2_installed(): + _logf("警告:注册表里没查到 WebView2 运行时,页面可能空白;" + "装一下 Edge WebView2 Runtime 即可") + + try: + webview.start(private_mode=False, storage_path=storage) + except TypeError: + # 老版本 pywebview 不认识这两个参数,退回默认调用 + webview.start() except Exception as e: - print("[警告] 内嵌窗口启动失败,降级为浏览器:%s" % e) + _logf("[警告] 内嵌窗口启动失败,降级为浏览器:%r" % e) _fallback_browser(url) # 内嵌窗口失败时保持服务运行,避免立即退出 try: