288 lines
8.4 KiB
Python
288 lines
8.4 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""
|
|||
|
|
神射手 桌面启动器 - 带进度条
|
|||
|
|
- 本地版:Ollama → Docker → MongoDB → pnpm dev → 打开浏览器
|
|||
|
|
- NAS版(--nas):检查 NAS 可达性 → 打开 http://192.168.1.201:3117
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import subprocess
|
|||
|
|
import signal
|
|||
|
|
import threading
|
|||
|
|
|
|||
|
|
USE_GUI = True
|
|||
|
|
try:
|
|||
|
|
import tkinter as tk
|
|||
|
|
from tkinter import ttk
|
|||
|
|
except ImportError:
|
|||
|
|
USE_GUI = False
|
|||
|
|
|
|||
|
|
PROJECT = "/Users/karuo/Documents/开发/2、私域银行/神射手"
|
|||
|
|
import glob
|
|||
|
|
PATH_DIRS = [
|
|||
|
|
"/opt/homebrew/bin", "/usr/local/bin",
|
|||
|
|
os.path.expanduser("~/.volta/bin"),
|
|||
|
|
]
|
|||
|
|
for d in PATH_DIRS:
|
|||
|
|
if os.path.isdir(d):
|
|||
|
|
os.environ["PATH"] = d + os.pathsep + os.environ.get("PATH", "")
|
|||
|
|
for p in glob.glob(os.path.expanduser("~/.fnm/*/installation/bin")):
|
|||
|
|
if os.path.isdir(p):
|
|||
|
|
os.environ["PATH"] = p + os.pathsep + os.environ.get("PATH", "")
|
|||
|
|
for p in glob.glob(os.path.expanduser("~/.nvm/versions/node/*/bin")):
|
|||
|
|
if os.path.isdir(p):
|
|||
|
|
os.environ["PATH"] = p + os.pathsep + os.environ.get("PATH", "")
|
|||
|
|
|
|||
|
|
server_pid = None
|
|||
|
|
_widgets = {} # 存放控件引用
|
|||
|
|
NAS_MODE = "--nas" in sys.argv or "-n" in sys.argv
|
|||
|
|
NAS_URL = "http://192.168.1.201:3117"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def notify(msg):
|
|||
|
|
"""发送 macOS 通知(无 GUI 时使用)"""
|
|||
|
|
subprocess.run(
|
|||
|
|
["osascript", "-e", f'display notification "{msg}" with title "神射手"'],
|
|||
|
|
capture_output=True, timeout=2
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def set_progress(pct, step, msg):
|
|||
|
|
"""线程安全更新进度"""
|
|||
|
|
if not USE_GUI:
|
|||
|
|
return
|
|||
|
|
w = _widgets
|
|||
|
|
if "pct" in w:
|
|||
|
|
w["pct"]["text"] = f"{pct}%"
|
|||
|
|
if "step" in w:
|
|||
|
|
w["step"]["text"] = step
|
|||
|
|
if "msg" in w:
|
|||
|
|
w["msg"]["text"] = msg
|
|||
|
|
if "progress" in w:
|
|||
|
|
w["progress"]["value"] = pct
|
|||
|
|
if "root" in w:
|
|||
|
|
try:
|
|||
|
|
w["root"].update_idletasks()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def port_listen(port):
|
|||
|
|
"""检测端口是否在监听"""
|
|||
|
|
try:
|
|||
|
|
r = subprocess.run(
|
|||
|
|
["lsof", "-i", f":{port}", "-sTCP:LISTEN"],
|
|||
|
|
capture_output=True, timeout=2
|
|||
|
|
)
|
|||
|
|
return r.returncode == 0
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def http_ok(url, timeout=2):
|
|||
|
|
"""检测 HTTP 是否可访问"""
|
|||
|
|
try:
|
|||
|
|
import urllib.request
|
|||
|
|
req = urllib.request.Request(url)
|
|||
|
|
urllib.request.urlopen(req, timeout=timeout)
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_steps_nas(root, start_time):
|
|||
|
|
"""NAS 版:检查 NAS 可达性 → 打开浏览器"""
|
|||
|
|
def elapsed():
|
|||
|
|
return int(time.time() - start_time)
|
|||
|
|
|
|||
|
|
def upd(pct, step, msg):
|
|||
|
|
full = f"{msg} (已用时 {elapsed()} 秒)"
|
|||
|
|
set_progress(pct, step, full)
|
|||
|
|
if not USE_GUI:
|
|||
|
|
notify(f"{pct}% | {step}: {msg}")
|
|||
|
|
|
|||
|
|
upd(0, "神射手 (NAS)", "检查 NAS 连通性...")
|
|||
|
|
if http_ok(NAS_URL, timeout=5):
|
|||
|
|
upd(90, "神射手 (NAS)", "NAS 服务可用")
|
|||
|
|
else:
|
|||
|
|
upd(50, "神射手 (NAS)", "NAS 可能未启动,仍尝试打开...")
|
|||
|
|
time.sleep(1)
|
|||
|
|
upd(100, "完成", "正在打开浏览器...")
|
|||
|
|
subprocess.run(["open", NAS_URL], timeout=3)
|
|||
|
|
time.sleep(2)
|
|||
|
|
# NAS 版无后台进程,2 秒后自动关闭窗口
|
|||
|
|
if root is not None and "root" in _widgets:
|
|||
|
|
try:
|
|||
|
|
r = _widgets["root"]
|
|||
|
|
r.after(0, r.destroy)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_steps(root, start_time):
|
|||
|
|
"""root 为 None 时表示无 GUI 模式"""
|
|||
|
|
global server_pid
|
|||
|
|
|
|||
|
|
if NAS_MODE:
|
|||
|
|
run_steps_nas(root, start_time)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
def elapsed():
|
|||
|
|
return int(time.time() - start_time)
|
|||
|
|
|
|||
|
|
def upd(pct, step, msg):
|
|||
|
|
full = f"{msg} (已用时 {elapsed()} 秒)"
|
|||
|
|
set_progress(pct, step, full)
|
|||
|
|
if not USE_GUI:
|
|||
|
|
notify(f"{pct}% | {step}: {msg}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
os.chdir(PROJECT)
|
|||
|
|
except Exception as e:
|
|||
|
|
upd(0, "错误", str(e))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 1. Ollama 本地大模型 (0-15%)
|
|||
|
|
upd(0, "本地大模型", "检查 Ollama (localhost:11434)...")
|
|||
|
|
if not http_ok("http://localhost:11434/api/tags"):
|
|||
|
|
upd(2, "本地大模型", "正在启动 Ollama...")
|
|||
|
|
subprocess.run(["open", "-a", "Ollama"], capture_output=True, timeout=5)
|
|||
|
|
for i in range(45):
|
|||
|
|
if http_ok("http://localhost:11434/api/tags"):
|
|||
|
|
break
|
|||
|
|
upd(2 + i // 3, "本地大模型", f"等待 Ollama 就绪... ({i+1}s)")
|
|||
|
|
time.sleep(1)
|
|||
|
|
upd(15, "本地大模型", "Ollama 已就绪")
|
|||
|
|
|
|||
|
|
# 2. Docker (15-35%)
|
|||
|
|
upd(15, "Docker", "检查 Docker Engine...")
|
|||
|
|
subprocess.run(["open", "-a", "Docker"], capture_output=True, timeout=5)
|
|||
|
|
for i in range(60):
|
|||
|
|
r = subprocess.run(["docker", "info"], capture_output=True, timeout=5)
|
|||
|
|
if r.returncode == 0:
|
|||
|
|
break
|
|||
|
|
upd(15 + i // 3, "Docker", f"等待 Docker Engine... ({i*2}s)")
|
|||
|
|
time.sleep(2)
|
|||
|
|
upd(35, "Docker", "Docker 已就绪")
|
|||
|
|
|
|||
|
|
# 3. MongoDB (35-45%)
|
|||
|
|
upd(35, "MongoDB", "启动 MongoDB 容器...")
|
|||
|
|
subprocess.run(["docker", "start", "datacenter_mongodb"], capture_output=True, timeout=10)
|
|||
|
|
time.sleep(3)
|
|||
|
|
upd(45, "MongoDB", "MongoDB 已就绪")
|
|||
|
|
|
|||
|
|
# 4. 若 3117 已监听,直接完成
|
|||
|
|
if port_listen(3117):
|
|||
|
|
upd(95, "神射手", "服务已在运行")
|
|||
|
|
time.sleep(1)
|
|||
|
|
subprocess.run(["open", "http://localhost:3117"], timeout=3)
|
|||
|
|
upd(100, "完成", "已打开浏览器")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 5. 启动 pnpm dev (45-90%)
|
|||
|
|
upd(45, "神射手", "启动开发服务 (pnpm dev)...")
|
|||
|
|
proc = subprocess.Popen(
|
|||
|
|
["pnpm", "dev"],
|
|||
|
|
cwd=PROJECT,
|
|||
|
|
stdout=subprocess.DEVNULL,
|
|||
|
|
stderr=subprocess.DEVNULL,
|
|||
|
|
start_new_session=True,
|
|||
|
|
)
|
|||
|
|
server_pid = proc.pid
|
|||
|
|
|
|||
|
|
# 6. 等待 3117 (90-100%)
|
|||
|
|
for i in range(90):
|
|||
|
|
if port_listen(3117):
|
|||
|
|
break
|
|||
|
|
pct = 45 + min(44, i * 2)
|
|||
|
|
upd(pct, "神射手", f"等待端口 3117 就绪... ({i+1}s)")
|
|||
|
|
time.sleep(1)
|
|||
|
|
|
|||
|
|
upd(95, "神射手", "正在打开浏览器...")
|
|||
|
|
subprocess.run(["open", "http://localhost:3117"], timeout=3)
|
|||
|
|
upd(100, "完成", "神射手已启动")
|
|||
|
|
|
|||
|
|
# 有 GUI 时保持进程直到窗口关闭;无 GUI 时直接退出(pnpm 在后台继续运行)
|
|||
|
|
if root is not None:
|
|||
|
|
try:
|
|||
|
|
proc.wait()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
global server_pid
|
|||
|
|
|
|||
|
|
if not USE_GUI:
|
|||
|
|
run_steps(None, time.time())
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
root = tk.Tk()
|
|||
|
|
root.title("神射手 (NAS) - 启动中" if NAS_MODE else "神射手 - 启动中")
|
|||
|
|
root.geometry("420x180")
|
|||
|
|
root.resizable(False, False)
|
|||
|
|
|
|||
|
|
f = ttk.Frame(root, padding=16)
|
|||
|
|
f.pack(fill=tk.BOTH, expand=True)
|
|||
|
|
|
|||
|
|
title = "神射手 (NAS) 数据中台" if NAS_MODE else "神射手 数据中台"
|
|||
|
|
subtitle = "NAS 192.168.1.201:3117" if NAS_MODE else "Ollama → Docker → MongoDB → 神射手"
|
|||
|
|
ttk.Label(f, text=title, font=("", 14, "bold")).pack(anchor=tk.W)
|
|||
|
|
ttk.Label(f, text=subtitle, font=("", 9)).pack(anchor=tk.W)
|
|||
|
|
|
|||
|
|
f2 = ttk.Frame(f)
|
|||
|
|
f2.pack(fill=tk.X, pady=(12, 0))
|
|||
|
|
|
|||
|
|
lb_pct = ttk.Label(f2, text="0%", font=("", 11, "bold"))
|
|||
|
|
lb_pct.pack(side=tk.RIGHT)
|
|||
|
|
lb_step = ttk.Label(f2, text="初始化", font=("", 10))
|
|||
|
|
lb_step.pack(anchor=tk.W)
|
|||
|
|
lb_msg = ttk.Label(f2, text="准备启动...", font=("", 9))
|
|||
|
|
lb_msg.pack(anchor=tk.W)
|
|||
|
|
p = ttk.Progressbar(f2, length=380, mode="determinate")
|
|||
|
|
p.pack(fill=tk.X, pady=(4, 0))
|
|||
|
|
p["value"] = 0
|
|||
|
|
|
|||
|
|
_widgets["root"] = root
|
|||
|
|
_widgets["pct"] = lb_pct
|
|||
|
|
_widgets["step"] = lb_step
|
|||
|
|
_widgets["msg"] = lb_msg
|
|||
|
|
_widgets["progress"] = p
|
|||
|
|
|
|||
|
|
def on_close():
|
|||
|
|
global server_pid
|
|||
|
|
if server_pid and not NAS_MODE:
|
|||
|
|
try:
|
|||
|
|
os.kill(server_pid, signal.SIGTERM)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
root.destroy()
|
|||
|
|
|
|||
|
|
root.protocol("WM_DELETE_WINDOW", on_close)
|
|||
|
|
root.update_idletasks()
|
|||
|
|
except Exception:
|
|||
|
|
run_steps(None, time.time())
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
t = threading.Thread(target=lambda: run_steps(root, time.time()))
|
|||
|
|
t.daemon = True
|
|||
|
|
t.start()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
root.mainloop()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
if server_pid and t.is_alive():
|
|||
|
|
try:
|
|||
|
|
os.kill(server_pid, signal.SIGTERM)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|