启动器:修复打包后自我套娃开窗,补运行日志与排查文档
根因:start_gmweb() 用 sys.executable 拉起 gmweb/serve.py,源码运行时是 python.exe,PyInstaller 单文件打包后却是启动器自身 —— 于是 `exe serve.py` 又开了一个启动器,新启动器再去拉 serve.py,每代顺延一个端口并各开一个 空窗口,直到 19546 端口用尽。开发机因为 18112 早被占用走了 early return, 所以看不出来。 - 新增多调用模式 run_script_instead():exe <xxx.py> [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 同步
This commit is contained in:
1 parent
da502b3524
commit
c6587d88ee
6 files changed
+442
-24
No files matched your search
@@ -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 <xxx.py> [args]` 会用 `runpy` 直接执行该脚本。打包后 `sys.executable` 就是 exe 自己,`start_gmweb()` 正是靠这条路径拉起 `gmweb/serve.py` 的
|
||||
- **提权**:UAC 自提权重启会带上原始参数;`DNF90_GMWEB_CHILD=1` 的子进程不再提权
|
||||
- **WebView2 数据目录**:固定在 `%LOCALAPPDATA%\DNF90Launcher\WebView2`,避免 exe 在只读目录/网络盘时窗口空白
|
||||
- 排障与现场取证见 [`docs/启动器窗口风暴排查.md`](../docs/启动器窗口风暴排查.md)
|
||||
|
||||
## 打包
|
||||
|
||||
|
||||
+297
-24
@@ -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 带 <xxx.py> 参数启动时,直接执行那个脚本。
|
||||
|
||||
打包成单文件后 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:
|
||||
|
||||
Reference in new issue
Block a user