guanlan_start_hidden.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  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'
  40. def log(msg: str) -> None:
  41. P.LOGS.mkdir(parents=True, exist_ok=True)
  42. line = f'{dt.datetime.now():%Y-%m-%d %H:%M:%S} {msg}'
  43. with open(LOG, 'a', encoding='utf-8') as f:
  44. f.write(line + '\n')
  45. def port_up(host: str, port: int, t: float = 0.4) -> bool:
  46. try:
  47. with socket.create_connection((host, port), timeout=t):
  48. return True
  49. except OSError:
  50. return False
  51. def healthz(url: str, timeout=6.0) -> bool:
  52. try:
  53. with urllib.request.urlopen(url, timeout=timeout) as r:
  54. return r.status == 200
  55. except Exception:
  56. return False
  57. def notify(title: str, text: str) -> None:
  58. """无窗口模式下唯一能让人看见失败的通道 (Windows 消息框; 非 Windows 走 stderr)。"""
  59. if os.name != 'nt':
  60. print(f'{title}: {text}', file=sys.stderr)
  61. return
  62. try:
  63. import ctypes
  64. ctypes.windll.user32.MessageBoxW(0, text, title, 0x30) # MB_ICONWARNING
  65. except Exception:
  66. pass
  67. def console_hwnd() -> int:
  68. """本进程有没有控制台窗口 (0 = 没有, 即"无窗口"成立)。非 Windows 返回 -1 (不适用)。"""
  69. if os.name != 'nt':
  70. return -1
  71. try:
  72. import ctypes
  73. return int(ctypes.windll.kernel32.GetConsoleWindow())
  74. except Exception:
  75. return -2
  76. def main() -> int:
  77. ap = argparse.ArgumentParser()
  78. ap.add_argument('--host', default='127.0.0.1')
  79. ap.add_argument('--gateway', type=int, default=None, help='网关端口 (默认读 configs/serve.json)')
  80. ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
  81. ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
  82. ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
  83. ap.add_argument('--selftest', action='store_true',
  84. help='"无窗口"自证: 打印本进程有没有控制台窗口就退出 (给验证用, 不启服务)')
  85. a = ap.parse_args()
  86. if a.selftest:
  87. h = console_hwnd()
  88. print(json.dumps(dict(console_hwnd=h, windowless=(h == 0),
  89. interpreter=sys.executable, argv0=sys.argv[0]),
  90. ensure_ascii=False))
  91. return 0
  92. gw = a.gateway
  93. if gw is None:
  94. try:
  95. cfg = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
  96. gw = int(cfg.get('gateway') or 28084)
  97. except Exception:
  98. gw = 28084
  99. url = f'http://{a.host}:{gw}/'
  100. _h = console_hwnd()
  101. _w = {0: '无控制台窗口 (= 无窗口启动成立)', -1: '非 Windows (不适用)', -2: '取不到 (视为未知)'}.get(_h, f'有控制台窗口 hwnd={_h}')
  102. log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s; 解释器 {sys.executable}) ---')
  103. log(f'窗口状态: {_w}')
  104. if port_up(a.host, gw):
  105. log(f'网关 {gw} 已在运行 → 只打开页面')
  106. if not a.no_open:
  107. import webbrowser
  108. webbrowser.open(url)
  109. return 0
  110. py = P.venv_python() or pathlib.Path(sys.executable)
  111. cmd = [str(py), 'guanlan.py', 'serve']
  112. flags = 0
  113. if os.name == 'nt':
  114. flags = (getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
  115. | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
  116. | getattr(subprocess, 'DETACHED_PROCESS', 0))
  117. P.LOGS.mkdir(parents=True, exist_ok=True)
  118. lf = open(P.LOGS / 'serve.log', 'ab')
  119. kw = dict(cwd=str(ROOT), stdout=lf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
  120. if os.name == 'nt':
  121. kw['creationflags'] = flags
  122. else:
  123. kw['start_new_session'] = True
  124. try:
  125. pid = subprocess.Popen(cmd, **kw).pid
  126. except Exception as e:
  127. log(f'✘ 起 serve 失败: {type(e).__name__}: {e}')
  128. if not a.quiet_fail:
  129. notify('观澜启动失败', f'无法启动服务: {e}\n详见 logs\\start_hidden.log')
  130. return 3
  131. log(f'起 serve pid={pid} (无窗口; 日志 logs/serve.log)')
  132. t0 = time.time()
  133. while time.time() - t0 < a.wait:
  134. if healthz(f'{url}healthz'):
  135. log(f'✔ 就绪 ({time.time() - t0:.0f}s) → {url}')
  136. if not a.no_open:
  137. try:
  138. import webbrowser
  139. webbrowser.open(url)
  140. except Exception as e:
  141. log(f'⚠ 打开浏览器失败: {e}')
  142. return 0
  143. time.sleep(1.5)
  144. log(f'✘ {a.wait}s 内 healthz 未就绪 (serve pid={pid} 可能已退出)')
  145. if not a.quiet_fail:
  146. notify('观澜启动未就绪', f'{a.wait} 秒内网关未就绪, 请查看:\n'
  147. f'logs\\serve.log\nlogs\\start_hidden.log')
  148. return 2
  149. if __name__ == '__main__':
  150. sys.exit(main())