guanlan_start_hidden.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """**无窗口**启动观澜 (2026-09-16 用户令: "启动观澜系统, 弹出的命令窗口, 改为不弹出方式")。
  4. 为什么单开一个脚本: `guanlan.py serve` 是**前台阻塞**命令 (它在控制台里打启动横幅、等 healthz),
  5. 双击 start.bat 就会一直挂着一个黑窗; 而各组件服务其实早已是 `DETACHED_PROCESS` 起的 (不弹窗)。
  6. 所以只需要一个"把 serve 藏到后台、等就绪、打开浏览器、自己退出"的入口。
  7. 做法:
  8. 1. 网关已在 → 只打开浏览器 (重复点不重复起, 幂等);
  9. 2. 否则用 `CREATE_NO_WINDOW | DETACHED_PROCESS` 起 `guanlan.py serve`, stdout/stderr 落
  10. `logs/serve.log` (有窗口才有地方看日志, 藏起来就必须落文件);
  11. 3. 轮询 `/healthz` 直到就绪 (最多 `--wait` 秒), 就绪后按 `--open/--no-open` 决定是否开浏览器;
  12. 4. 全过程写 `logs/start_hidden.log` (含退出码与失败原因) —— **不静默**:
  13. 失败时不但写日志, 还会弹一个消息框 (无窗口模式下唯一能让人看见的方式)。
  14. 由 `start_hidden.vbs` 以 `pythonw.exe` 调用 (窗口样式 0 = 完全不显示)。
  15. 用法:
  16. .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py # 隐藏启动 + 开浏览器
  17. .venv\\Scripts\\python.exe scripts\\guanlan_start_hidden.py --no-open # 隐藏启动, 不开浏览器
  18. .venv\\Scripts\\python.exe scripts\\guanlan_start_hidden.py --wait 180 # 加长等待
  19. """
  20. from __future__ import annotations
  21. import argparse
  22. import datetime as dt
  23. import json
  24. import os
  25. import pathlib
  26. import socket
  27. import subprocess
  28. import sys
  29. import time
  30. import urllib.request
  31. ROOT = pathlib.Path(__file__).resolve().parents[1]
  32. sys.path.insert(0, str(ROOT))
  33. from src import paths as P # noqa: E402
  34. from src.console import soft # noqa: E402
  35. soft()
  36. LOG = P.LOGS / 'start_hidden.log'
  37. def log(msg: str) -> None:
  38. P.LOGS.mkdir(parents=True, exist_ok=True)
  39. line = f'{dt.datetime.now():%Y-%m-%d %H:%M:%S} {msg}'
  40. with open(LOG, 'a', encoding='utf-8') as f:
  41. f.write(line + '\n')
  42. def port_up(host: str, port: int, t: float = 0.4) -> bool:
  43. try:
  44. with socket.create_connection((host, port), timeout=t):
  45. return True
  46. except OSError:
  47. return False
  48. def healthz(url: str, timeout=6.0) -> bool:
  49. try:
  50. with urllib.request.urlopen(url, timeout=timeout) as r:
  51. return r.status == 200
  52. except Exception:
  53. return False
  54. def notify(title: str, text: str) -> None:
  55. """无窗口模式下唯一能让人看见失败的通道 (Windows 消息框; 非 Windows 走 stderr)。"""
  56. if os.name != 'nt':
  57. print(f'{title}: {text}', file=sys.stderr)
  58. return
  59. try:
  60. import ctypes
  61. ctypes.windll.user32.MessageBoxW(0, text, title, 0x30) # MB_ICONWARNING
  62. except Exception:
  63. pass
  64. def main() -> int:
  65. ap = argparse.ArgumentParser()
  66. ap.add_argument('--host', default='127.0.0.1')
  67. ap.add_argument('--gateway', type=int, default=None, help='网关端口 (默认读 configs/serve.json)')
  68. ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
  69. ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
  70. ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
  71. a = ap.parse_args()
  72. gw = a.gateway
  73. if gw is None:
  74. try:
  75. cfg = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
  76. gw = int(cfg.get('gateway') or 28084)
  77. except Exception:
  78. gw = 28084
  79. url = f'http://{a.host}:{gw}/'
  80. log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s) ---')
  81. if port_up(a.host, gw):
  82. log(f'网关 {gw} 已在运行 → 只打开页面')
  83. if not a.no_open:
  84. import webbrowser
  85. webbrowser.open(url)
  86. return 0
  87. py = P.venv_python() or pathlib.Path(sys.executable)
  88. cmd = [str(py), 'guanlan.py', 'serve']
  89. flags = 0
  90. if os.name == 'nt':
  91. flags = (getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
  92. | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
  93. | getattr(subprocess, 'DETACHED_PROCESS', 0))
  94. P.LOGS.mkdir(parents=True, exist_ok=True)
  95. lf = open(P.LOGS / 'serve.log', 'ab')
  96. kw = dict(cwd=str(ROOT), stdout=lf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
  97. if os.name == 'nt':
  98. kw['creationflags'] = flags
  99. else:
  100. kw['start_new_session'] = True
  101. try:
  102. pid = subprocess.Popen(cmd, **kw).pid
  103. except Exception as e:
  104. log(f'✘ 起 serve 失败: {type(e).__name__}: {e}')
  105. if not a.quiet_fail:
  106. notify('观澜启动失败', f'无法启动服务: {e}\n详见 logs\\start_hidden.log')
  107. return 3
  108. log(f'起 serve pid={pid} (无窗口; 日志 logs/serve.log)')
  109. t0 = time.time()
  110. while time.time() - t0 < a.wait:
  111. if healthz(f'{url}healthz'):
  112. log(f'✔ 就绪 ({time.time() - t0:.0f}s) → {url}')
  113. if not a.no_open:
  114. try:
  115. import webbrowser
  116. webbrowser.open(url)
  117. except Exception as e:
  118. log(f'⚠ 打开浏览器失败: {e}')
  119. return 0
  120. time.sleep(1.5)
  121. log(f'✘ {a.wait}s 内 healthz 未就绪 (serve pid={pid} 可能已退出)')
  122. if not a.quiet_fail:
  123. notify('观澜启动未就绪', f'{a.wait} 秒内网关未就绪, 请查看:\n'
  124. f'logs\\serve.log\nlogs\\start_hidden.log')
  125. return 2
  126. if __name__ == '__main__':
  127. sys.exit(main())