mirror of
https://github.com/countbot-ai/CountBot.git
synced 2026-09-14 20:46:47 +08:00
376 lines
11 KiB
Python
376 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""CountBot Desktop 启动入口"""
|
|
|
|
import http.client
|
|
import os
|
|
import platform
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from backend.utils.runtime_env import (
|
|
apply_bind_env,
|
|
get_local_client_host,
|
|
is_public_bind_host,
|
|
resolve_bind_address,
|
|
)
|
|
|
|
|
|
if sys.platform == "win32":
|
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
|
try:
|
|
import ctypes
|
|
|
|
kernel32 = ctypes.windll.kernel32
|
|
kernel32.SetConsoleCP(65001)
|
|
kernel32.SetConsoleOutputCP(65001)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if getattr(sys, "frozen", False):
|
|
if hasattr(sys, "_MEIPASS"):
|
|
PROJECT_ROOT = Path(sys._MEIPASS)
|
|
else:
|
|
exe_dir = Path(sys.executable).parent
|
|
if sys.platform == "darwin":
|
|
if (exe_dir / "_internal").exists():
|
|
PROJECT_ROOT = exe_dir / "_internal"
|
|
else:
|
|
PROJECT_ROOT = exe_dir.parent / "Resources"
|
|
else:
|
|
PROJECT_ROOT = (
|
|
exe_dir / "_internal" if (exe_dir / "_internal").exists() else exe_dir
|
|
)
|
|
else:
|
|
PROJECT_ROOT = Path(__file__).parent
|
|
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
|
|
_server = None
|
|
_backend_error: str | None = None
|
|
RESOURCES_DIR = PROJECT_ROOT / "resources"
|
|
|
|
|
|
def show_error_dialog(title: str, message: str) -> None:
|
|
"""显示错误对话框。"""
|
|
if sys.platform == "darwin" and threading.current_thread() != threading.main_thread():
|
|
print(f"\n{'=' * 60}\n错误: {title}\n{'=' * 60}\n{message}\n{'=' * 60}\n")
|
|
return
|
|
|
|
try:
|
|
import tkinter as tk
|
|
from tkinter import messagebox
|
|
|
|
root = tk.Tk()
|
|
root.withdraw()
|
|
messagebox.showerror(title, message)
|
|
root.destroy()
|
|
except Exception:
|
|
print(f"\n{'=' * 60}\n错误: {title}\n{'=' * 60}\n{message}\n{'=' * 60}\n")
|
|
|
|
|
|
def check_dependencies() -> tuple[bool, str]:
|
|
"""检查桌面端运行依赖。"""
|
|
missing = []
|
|
for pkg in ["webview", "fastapi", "uvicorn"]:
|
|
try:
|
|
__import__(pkg)
|
|
except ImportError:
|
|
missing.append(pkg if pkg != "webview" else "pywebview")
|
|
|
|
if missing:
|
|
msg = (
|
|
f"缺少依赖: {', '.join(missing)}\n\n"
|
|
"安装命令:\n"
|
|
"pip install -r requirements.txt"
|
|
)
|
|
return False, msg
|
|
return True, ""
|
|
|
|
|
|
def get_icon_path() -> str | None:
|
|
"""获取图标路径。"""
|
|
name_map = {"Windows": "countbot.ico", "Darwin": "countbot.icns"}
|
|
icon = RESOURCES_DIR / name_map.get(platform.system(), "countbot.png")
|
|
return str(icon) if icon.exists() else None
|
|
|
|
|
|
def _set_macos_dock_icon(path: str) -> None:
|
|
"""设置 macOS Dock 图标。"""
|
|
try:
|
|
from AppKit import NSApplication, NSImage
|
|
|
|
img = NSImage.alloc().initWithContentsOfFile_(path)
|
|
if img:
|
|
NSApplication.sharedApplication().setApplicationIconImage_(img)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _set_windows_app_id() -> None:
|
|
"""设置 Windows 任务栏 AppUserModelID。"""
|
|
try:
|
|
import ctypes
|
|
|
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("countbot.desktop.app")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _apply_windows_window_icon(window, icon_path: str) -> None:
|
|
"""Apply a custom icon to the native WinForms window used by pywebview."""
|
|
if platform.system() != "Windows" or not icon_path:
|
|
return
|
|
|
|
try:
|
|
import ctypes
|
|
from System import Action
|
|
from System.Drawing import Icon
|
|
|
|
native_window = getattr(window, "native", None)
|
|
if native_window is None:
|
|
return
|
|
|
|
def _set_icon() -> None:
|
|
native_window.Icon = Icon(icon_path)
|
|
|
|
handle = native_window.Handle.ToInt32()
|
|
image_icon = 1
|
|
wm_seticon = 0x0080
|
|
icon_small = 0
|
|
icon_big = 1
|
|
lr_loadfromfile = 0x0010
|
|
lr_defaultsize = 0x0040
|
|
|
|
icon_handle = ctypes.windll.user32.LoadImageW(
|
|
None,
|
|
icon_path,
|
|
image_icon,
|
|
0,
|
|
0,
|
|
lr_loadfromfile | lr_defaultsize,
|
|
)
|
|
if icon_handle:
|
|
ctypes.windll.user32.SendMessageW(handle, wm_seticon, icon_small, icon_handle)
|
|
ctypes.windll.user32.SendMessageW(handle, wm_seticon, icon_big, icon_handle)
|
|
|
|
if native_window.InvokeRequired:
|
|
native_window.BeginInvoke(Action(_set_icon))
|
|
else:
|
|
_set_icon()
|
|
except Exception as exc:
|
|
from loguru import logger
|
|
|
|
logger.warning(f"应用 Windows 窗口图标失败: {exc}")
|
|
|
|
|
|
def _start_backend(host: str, port: int) -> None:
|
|
"""在后台线程启动 FastAPI 服务。"""
|
|
global _server, _backend_error
|
|
|
|
import uvicorn
|
|
from loguru import logger
|
|
|
|
from backend.utils.logger import build_uvicorn_log_config
|
|
|
|
try:
|
|
cfg = uvicorn.Config(
|
|
"backend.app:app",
|
|
host=host,
|
|
port=port,
|
|
reload=False,
|
|
log_level="info",
|
|
log_config=build_uvicorn_log_config(),
|
|
)
|
|
_server = uvicorn.Server(cfg)
|
|
_server.run()
|
|
except OSError as exc:
|
|
if "Address already in use" in str(exc) or "Only one usage" in str(exc):
|
|
_backend_error = (
|
|
f"端口 {port} 已被占用\n\n"
|
|
"解决方法:\n"
|
|
"1. 关闭其他实例\n"
|
|
"2. 修改环境变量 COUNTBOT_PORT\n"
|
|
" PowerShell: $env:COUNTBOT_PORT='8001'\n"
|
|
" CMD: set COUNTBOT_PORT=8001"
|
|
)
|
|
else:
|
|
_backend_error = f"后端服务启动失败:\n{exc}"
|
|
logger.exception(_backend_error)
|
|
except Exception as exc:
|
|
_backend_error = f"后端服务启动失败:\n{exc}"
|
|
logger.exception(_backend_error)
|
|
|
|
|
|
def _shutdown() -> None:
|
|
"""关闭后端服务。"""
|
|
global _server
|
|
if _server:
|
|
_server.should_exit = True
|
|
|
|
|
|
def _wait_for_server(host: str, port: int, timeout: float = 15.0) -> tuple[bool, str]:
|
|
"""等待后端就绪,并返回最后一次探活错误。"""
|
|
deadline = time.time() + timeout
|
|
last_error = ""
|
|
|
|
while time.time() < deadline:
|
|
if _backend_error:
|
|
return False, _backend_error
|
|
|
|
conn = None
|
|
try:
|
|
conn = http.client.HTTPConnection(host, port, timeout=2)
|
|
conn.request("GET", "/api/health")
|
|
response = conn.getresponse()
|
|
if response.status == 200:
|
|
return True, ""
|
|
last_error = f"/api/health 返回 HTTP {response.status}"
|
|
except Exception as exc:
|
|
last_error = f"{type(exc).__name__}: {exc}"
|
|
finally:
|
|
if conn is not None:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
time.sleep(0.3)
|
|
|
|
return False, last_error
|
|
|
|
|
|
def _check_frontend() -> tuple[bool, str]:
|
|
"""检查前端静态文件是否存在。"""
|
|
index = PROJECT_ROOT / "frontend" / "dist" / "index.html"
|
|
if not index.exists():
|
|
msg = (
|
|
f"前端文件不存在: {index}\n\n"
|
|
"解决方法:\n"
|
|
"cd frontend && npm install && npm run build"
|
|
)
|
|
return False, msg
|
|
return True, ""
|
|
|
|
|
|
def main() -> None:
|
|
"""桌面端主入口。"""
|
|
global _backend_error
|
|
|
|
from loguru import logger
|
|
|
|
deps_ok, deps_msg = check_dependencies()
|
|
if not deps_ok:
|
|
show_error_dialog("缺少依赖", deps_msg)
|
|
sys.exit(1)
|
|
|
|
import webview
|
|
|
|
if platform.system() == "Windows":
|
|
os.environ["PYWEBVIEW_GUI"] = "edgechromium"
|
|
logger.info("使用 EdgeChromium 渲染引擎")
|
|
|
|
host, port = resolve_bind_address()
|
|
apply_bind_env(host, port)
|
|
client_host = get_local_client_host(host)
|
|
startup_timeout = float(
|
|
os.getenv("BACKEND_STARTUP_TIMEOUT", "30" if getattr(sys, "frozen", False) else "15")
|
|
)
|
|
_backend_error = None
|
|
|
|
if is_public_bind_host(host):
|
|
logger.info(f"CountBot Desktop 启动中: http://{client_host}:{port} (bind: {host})")
|
|
else:
|
|
logger.info(f"CountBot Desktop 启动中: http://{client_host}:{port}")
|
|
|
|
frontend_ok, frontend_msg = _check_frontend()
|
|
if not frontend_ok:
|
|
logger.error(frontend_msg)
|
|
show_error_dialog("前端文件缺失", frontend_msg)
|
|
sys.exit(1)
|
|
|
|
logger.info("启动后端服务...")
|
|
threading.Thread(target=_start_backend, args=(host, port), daemon=True).start()
|
|
|
|
logger.info("等待后端就绪...")
|
|
server_ready, probe_error = _wait_for_server(client_host, port, timeout=startup_timeout)
|
|
if not server_ready:
|
|
details = f"\n\n最后探活错误: {probe_error}" if probe_error else ""
|
|
msg = (
|
|
f"后端启动超时 ({int(startup_timeout)}秒){details}\n\n"
|
|
"可能原因:\n"
|
|
f"1. 端口 {port} 被占用\n"
|
|
"2. 防火墙阻止\n"
|
|
"3. 资源不足"
|
|
)
|
|
logger.error(msg)
|
|
show_error_dialog("启动超时", msg)
|
|
sys.exit(1)
|
|
|
|
logger.info("后端服务就绪")
|
|
|
|
icon_path = get_icon_path()
|
|
if icon_path:
|
|
if platform.system() == "Darwin":
|
|
_set_macos_dock_icon(icon_path)
|
|
elif platform.system() == "Windows":
|
|
_set_windows_app_id()
|
|
|
|
try:
|
|
logger.info("创建应用窗口...")
|
|
window = webview.create_window(
|
|
title="CountBot Desktop",
|
|
url=f"http://{client_host}:{port}",
|
|
width=960,
|
|
height=680,
|
|
min_size=(220, 480),
|
|
resizable=True,
|
|
text_select=True,
|
|
)
|
|
window.events.closing += lambda: _shutdown()
|
|
if platform.system() == "Windows" and icon_path:
|
|
window.events.shown += lambda: _apply_windows_window_icon(window, icon_path)
|
|
|
|
start_kwargs = {"debug": os.getenv("DEBUG", "").lower() in ("1", "true")}
|
|
if icon_path:
|
|
start_kwargs["icon"] = icon_path
|
|
|
|
logger.info("CountBot Desktop 启动成功")
|
|
webview.start(**start_kwargs)
|
|
except Exception as exc:
|
|
msg = (
|
|
f"窗口创建失败:\n{exc}\n\n"
|
|
"Windows: 安装 Edge WebView2\n"
|
|
"Mac: 系统 >= 10.13\n"
|
|
"Linux: apt install webkit2gtk-4.0"
|
|
)
|
|
logger.error(msg)
|
|
show_error_dialog("窗口创建失败", msg)
|
|
sys.exit(1)
|
|
|
|
logger.info("CountBot Desktop 已退出")
|
|
os._exit(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
main()
|
|
except KeyboardInterrupt:
|
|
print("\n用户中断,退出...")
|
|
sys.exit(0)
|
|
except Exception as exc:
|
|
msg = (
|
|
f"程序错误:\n{exc}\n\n"
|
|
"1. 重启程序\n"
|
|
"2. 查看日志: data/logs/\n"
|
|
"3. 提交 Issue"
|
|
)
|
|
show_error_dialog("程序错误", msg)
|
|
import traceback
|
|
|
|
traceback.print_exc()
|
|
sys.exit(1)
|