mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 17:54:15 +08:00
Merge fix/desktop-launch: 修复 console=False 下桌面客户端打不开
This commit is contained in:
+110
-2
@@ -20,6 +20,7 @@ import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -85,6 +86,88 @@ def _release_single_instance() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _guard_streams() -> None:
|
||||
"""windowed 模式 (console=False) 下守护 stdout/stderr。
|
||||
|
||||
PyInstaller console=False 用 runw.exe 启动器, 不分配控制台, 此时 sys.stdout /
|
||||
sys.stderr 可能为 None (或底层句柄无效)。后果:
|
||||
- app/__init__.py 早期 reconfigure 遇 None 虽有 hasattr 保护, 但若是个「写入即崩」
|
||||
的伪 stream 对象, reconfigure 会成功、后续写却崩;
|
||||
- logging.basicConfig() 默认建 StreamHandler(sys.stderr), stderr 为 None 时
|
||||
首次写日志调 None.write() 抛 AttributeError, 此时往往在导入早期,
|
||||
Python 异常处理未就绪 → 进程直接闪退, try/except 都拦不住。
|
||||
|
||||
修法: console=False 下把 stdout/stderr 换成丢弃写入的空对象 (devnull),
|
||||
让 logging / reconfigure / 任何 print 都安全落地。console=True 不动 (有真控制台)。
|
||||
"""
|
||||
import os
|
||||
|
||||
class _NullStream:
|
||||
"""丢弃所有写入的空流 (替代 None 的 stdout/stderr)。"""
|
||||
def write(self, _s): return 0
|
||||
def flush(self): pass
|
||||
def reconfigure(self, *a, **kw): pass
|
||||
def isatty(self): return False
|
||||
def fileno(self): raise OSError("no fileno")
|
||||
|
||||
# 仅在 stdout/stderr 缺失或不可写时替换 (有真控制台时保持原样)
|
||||
for name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, name, None)
|
||||
if stream is None:
|
||||
setattr(sys, name, _NullStream())
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
"""配置日志落盘到 data/desktop.log。
|
||||
|
||||
背景: spec 里 console=False (桌面应用不弹黑窗), 导致 logging 默认输出的
|
||||
stderr 被吞掉 —— 启动期任何异常用户都看不到, 表现为「双击一闪退出、查无日志」。
|
||||
这里追加一个 FileHandler, 让日志同时落到 data_dir/desktop.log, 事后可查。
|
||||
|
||||
时序注意: data_dir 在 import app.config 时路径已可用, 但目录此刻可能不存在
|
||||
(frozen 首次运行), 必须先 mkdir, 否则 FileHandler 打开文件会抛 FileNotFoundError。
|
||||
不用第二次 basicConfig (它「首次调用才生效」), 改用 addHandler 追加。
|
||||
"""
|
||||
try:
|
||||
from app.config import settings
|
||||
|
||||
log_dir = settings.data_dir
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
handler = logging.FileHandler(
|
||||
log_dir / "desktop.log",
|
||||
mode="a", # 追加, 保留历史 (排查时往往需要对比多次启动)
|
||||
encoding="utf-8",
|
||||
errors="replace", # 容错: 对齐 __init__.py 的 stderr 重配, 避免中文/emoji 触发 UnicodeEncodeError
|
||||
)
|
||||
handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
)
|
||||
logging.getLogger().addHandler(handler)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 日志落盘失败不阻断启动 (开发模式 data_dir 可能不可写)
|
||||
logger.warning("日志文件初始化失败, 仅输出到 stderr: %s", e)
|
||||
|
||||
|
||||
def _show_crash(title: str, text: str) -> None:
|
||||
"""崩溃时弹原生 MessageBox 提示用户 (仅 Windows)。
|
||||
|
||||
console=False 下用户看不到任何输出, 崩溃时弹一个原生错误框, 让用户至少
|
||||
知道「程序崩了 + 原因」, 并可截图反馈。非 Windows 用日志降级, 不调 ctypes。
|
||||
|
||||
ctypes 是 Python 标准库, 不引入新依赖; MessageBoxW 是 Unicode 版本 (W 后缀),
|
||||
支持中文标题/正文。0x10 = MB_ICONERROR (红色错误图标)。
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.user32.MessageBoxW(0, text, title, 0x10)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("弹框失败 (已写日志文件): %s", e)
|
||||
else:
|
||||
logger.error("%s: %s", title, text)
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""检查指定 PID 的进程是否存活。"""
|
||||
import os
|
||||
@@ -127,8 +210,15 @@ def _run_uvmicorn(port: int, ready_event: threading.Event) -> None:
|
||||
"""后台线程: 启动 uvicorn 服务。ready_event 在线程退出时置位 (通知主线程)。"""
|
||||
import uvicorn
|
||||
|
||||
# 延迟 import app, 确保配置层已就绪 (frozen 检测在 config.py 导入时完成)
|
||||
from app.main import app
|
||||
try:
|
||||
# 延迟 import app, 确保配置层已就绪 (frozen 检测在 config.py 导入时完成)
|
||||
# 放进 try: app.main 模块导入 (含 app.api.* 一长串 import) 若失败,
|
||||
# 异常必须落到 except 记录, 否则主线程只看到「后端超时」而查无 traceback。
|
||||
from app.main import app
|
||||
except Exception:
|
||||
logger.exception("后端模块导入失败 (app.main 或其依赖)")
|
||||
ready_event.set()
|
||||
return
|
||||
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
@@ -147,6 +237,11 @@ def _run_uvmicorn(port: int, ready_event: threading.Event) -> None:
|
||||
|
||||
try:
|
||||
server.run()
|
||||
except Exception:
|
||||
# server.run() 内部跑 lifespan 启动链, 任一步抛异常都会冒到这里。
|
||||
# 不捕获则线程静默死亡, 主线程 _wait_for_server 傻等满 60s 后报「超时」,
|
||||
# 真正的崩溃原因 (如某个原生库加载失败 / 缺 hidden import) 永远看不到。
|
||||
logger.exception("uvicorn 后端启动/运行失败")
|
||||
finally:
|
||||
ready_event.set()
|
||||
|
||||
@@ -191,10 +286,16 @@ def _open_window(url: str) -> None:
|
||||
|
||||
def main() -> int:
|
||||
"""桌面客户端主入口。返回进程退出码。"""
|
||||
# 必须最先执行: console=False 下 stdout/stderr 可能无效, 不守护会导致
|
||||
# 后续 logging.basicConfig 创建的 StreamHandler 写日志时进程崩溃。
|
||||
_guard_streams()
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
# 追加文件日志: console=False 下 stderr 被吞, 必须落盘否则查无对证。
|
||||
# 放在 basicConfig 之后 (它先建好 root logger 的格式), 这里只追加 handler。
|
||||
_setup_logging()
|
||||
|
||||
try:
|
||||
_ensure_data_dir_writable()
|
||||
@@ -233,6 +334,13 @@ def main() -> int:
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
except Exception:
|
||||
# 顶层兜底: console=False 下未捕获异常会「一闪退出」且无任何反馈。
|
||||
# 写完整 traceback 到 data/desktop.log, 并弹原生 MessageBox 让用户截图反馈。
|
||||
# 必须排在 KeyboardInterrupt 之后 —— Exception 是基类, 在前会遮蔽它。
|
||||
logger.exception("桌面客户端启动失败")
|
||||
_show_crash("TickFlow 启动失败", traceback.format_exc())
|
||||
return 1
|
||||
finally:
|
||||
_release_single_instance()
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ exe = EXE(
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False, # UPX 压缩原生库常导致崩溃, 关闭
|
||||
console=False, # 桌面应用: 不显示控制台窗口 (调试时临时改 True 抓日志)
|
||||
console=False, # 桌面应用: 不显示控制台窗口 (_guard_streams 守护 stdout/stderr)
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
||||
Reference in New Issue
Block a user