guanlan_start_hidden.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  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. 由 `pythonw.exe`(无控制台子系统的解释器) 调用 —— 2026-09-16 用户令"把 VBScript 替换掉"之后,
  15. 不再经 `start_hidden.vbs` / Windows 脚本宿主: `start.bat` 与安装时生成的 `启动观澜.lnk` 都直接
  16. 指向 `.venv\\Scripts\\pythonw.exe` + 本脚本, WSH 被禁用或未安装也照样无窗口启动。
  17. 用法:
  18. .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py # 无窗口启动 + 开浏览器
  19. .venv\\Scripts\\python.exe scripts\\guanlan_start_hidden.py --no-open # 无窗口启动, 不开浏览器
  20. .venv\\Scripts\\python.exe scripts\\guanlan_start_hidden.py --wait 180 # 加长等待
  21. .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py --selftest # 只打印"有没有控制台"就退出
  22. """
  23. from __future__ import annotations
  24. import argparse
  25. import datetime as dt
  26. import json
  27. import os
  28. import pathlib
  29. import socket
  30. import subprocess
  31. import sys
  32. import time
  33. import urllib.request
  34. ROOT = pathlib.Path(__file__).resolve().parents[1]
  35. sys.path.insert(0, str(ROOT))
  36. from src import paths as P # noqa: E402
  37. from src.console import soft # noqa: E402
  38. soft()
  39. LOG = P.LOGS / 'start_hidden.log' # 启动器日志: 服务日志命名口径 logs/<组件>.log
  40. def log(msg: str) -> None:
  41. """写启动器日志 —— **走统一行格式** `时间戳 级别 组件 消息` (用户令 2 / docs §11.2)。
  42. 2026-09-17 实测: 这里原来是自己的 `时间戳 消息`(没有级别与组件), log_audit 会报"不符合统一格式";
  43. 统一后与 6 个服务、运维动作日志一套格式, 排障时一眼能对上。
  44. """
  45. from src import logfile as _lf
  46. P.LOGS.mkdir(parents=True, exist_ok=True)
  47. with open(LOG, 'a', encoding='utf-8', newline='\n') as f:
  48. f.write(_lf.line('INFO', 'start_hidden', msg) + '\n')
  49. def port_up(host: str, port: int, t: float = 0.4) -> bool:
  50. try:
  51. with socket.create_connection((host, port), timeout=t):
  52. return True
  53. except OSError:
  54. return False
  55. def healthz(url: str, timeout=6.0) -> bool:
  56. try:
  57. with urllib.request.urlopen(url, timeout=timeout) as r:
  58. return r.status == 200
  59. except Exception:
  60. return False
  61. def notify(title: str, text: str) -> None:
  62. """无窗口模式下唯一能让人看见失败的通道 (Windows 消息框; 非 Windows 走 stderr)。"""
  63. if os.name != 'nt':
  64. print(f'{title}: {text}', file=sys.stderr)
  65. return
  66. try:
  67. import ctypes
  68. ctypes.windll.user32.MessageBoxW(0, text, title, 0x30) # MB_ICONWARNING
  69. except Exception:
  70. pass
  71. def console_hwnd() -> int:
  72. """本进程有没有控制台窗口 (0 = 没有, 即"无窗口"成立)。非 Windows 返回 -1 (不适用)。"""
  73. if os.name != 'nt':
  74. return -1
  75. try:
  76. import ctypes
  77. return int(ctypes.windll.kernel32.GetConsoleWindow())
  78. except Exception:
  79. return -2
  80. def main() -> int:
  81. ap = argparse.ArgumentParser()
  82. ap.add_argument('--host', default='127.0.0.1')
  83. ap.add_argument('--gateway', type=int, default=None, help='网关端口 (默认读 configs/serve.json)')
  84. ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
  85. ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
  86. ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
  87. ap.add_argument('--selftest', action='store_true',
  88. help='"无窗口"自证: 打印本进程有没有控制台窗口就退出 (给验证用, 不启服务)')
  89. a = ap.parse_args()
  90. if a.selftest:
  91. h = console_hwnd()
  92. print(json.dumps(dict(console_hwnd=h, windowless=(h == 0),
  93. interpreter=sys.executable, argv0=sys.argv[0]),
  94. ensure_ascii=False))
  95. return 0
  96. gw = a.gateway
  97. if gw is None:
  98. try:
  99. cfg = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
  100. gw = int(cfg.get('gateway') or 28084)
  101. except Exception:
  102. gw = 28084
  103. url = f'http://{a.host}:{gw}/'
  104. _h = console_hwnd()
  105. _w = {0: '无控制台窗口 (= 无窗口启动成立)', -1: '非 Windows (不适用)', -2: '取不到 (视为未知)'}.get(_h, f'有控制台窗口 hwnd={_h}')
  106. log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s; 解释器 {sys.executable}) ---')
  107. log(f'窗口状态: {_w}')
  108. if port_up(a.host, gw):
  109. log(f'网关 {gw} 已在运行 → 只打开页面')
  110. if not a.no_open:
  111. import webbrowser
  112. webbrowser.open(url)
  113. return 0
  114. py = P.venv_python() or pathlib.Path(sys.executable)
  115. cmd = [str(py), 'guanlan.py', 'serve']
  116. flags = 0
  117. if os.name == 'nt':
  118. flags = (getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
  119. | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
  120. | getattr(subprocess, 'DETACHED_PROCESS', 0))
  121. P.LOGS.mkdir(parents=True, exist_ok=True)
  122. lf = open(P.LOGS / 'serve.log', 'ab')
  123. kw = dict(cwd=str(ROOT), stdout=lf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
  124. if os.name == 'nt':
  125. kw['creationflags'] = flags
  126. else:
  127. kw['start_new_session'] = True
  128. try:
  129. pid = subprocess.Popen(cmd, **kw).pid
  130. except Exception as e:
  131. log(f'✘ 起 serve 失败: {type(e).__name__}: {e}')
  132. if not a.quiet_fail:
  133. notify('观澜启动失败', f'无法启动服务: {e}\n详见 logs\\start_hidden.log')
  134. return 3
  135. log(f'起 serve pid={pid} (无窗口; 日志 logs/serve.log)')
  136. t0 = time.time()
  137. while time.time() - t0 < a.wait:
  138. if healthz(f'{url}healthz'):
  139. log(f'✔ 就绪 ({time.time() - t0:.0f}s) → {url}')
  140. if not a.no_open:
  141. try:
  142. import webbrowser
  143. webbrowser.open(url)
  144. except Exception as e:
  145. log(f'⚠ 打开浏览器失败: {e}')
  146. return 0
  147. time.sleep(1.5)
  148. log(f'✘ {a.wait}s 内 healthz 未就绪 (serve pid={pid} 可能已退出)')
  149. if not a.quiet_fail:
  150. notify('观澜启动未就绪', f'{a.wait} 秒内网关未就绪, 请查看:\n'
  151. f'logs\\serve.log\nlogs\\start_hidden.log')
  152. return 2
  153. if __name__ == '__main__':
  154. sys.exit(main())