start_hidden.py 7.8 KB

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