| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """**无窗口**启动观澜 (2026-09-16 用户令: "启动观澜系统, 弹出的命令窗口, 改为不弹出方式")。
- 为什么单开一个脚本: `guanlan.py serve` 是**前台阻塞**命令 (它在控制台里打启动横幅、等 healthz),
- 双击 start.bat 就会一直挂着一个黑窗; 而各组件服务其实早已是 `DETACHED_PROCESS` 起的 (不弹窗)。
- 所以只需要一个"把 serve 藏到后台、等就绪、打开浏览器、自己退出"的入口。
- 做法:
- 1. 网关已在 → 只打开浏览器 (重复点不重复起, 幂等);
- 2. 否则用 `CREATE_NO_WINDOW | DETACHED_PROCESS` 起 `guanlan.py serve`, stdout/stderr 落
- `logs/serve.log` (有窗口才有地方看日志, 藏起来就必须落文件);
- 3. 轮询 `/healthz` 直到就绪 (最多 `--wait` 秒), 就绪后按 `--open/--no-open` 决定是否开浏览器;
- 4. 全过程写 `logs/start_hidden.log` (含退出码与失败原因) —— **不静默**:
- 失败时不但写日志, 还会弹一个消息框 (无窗口模式下唯一能让人看见的方式)。
- 由 `pythonw.exe`(无控制台子系统的解释器) 调用 —— 2026-09-16 用户令"把 VBScript 替换掉"之后,
- 不再经 `start_hidden.vbs` / Windows 脚本宿主: `start.bat` 与安装时生成的 `启动观澜.lnk` 都直接
- 指向 `.venv\\Scripts\\pythonw.exe` + 本脚本, WSH 被禁用或未安装也照样无窗口启动。
- 用法:
- .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py # 无窗口启动 + 开浏览器
- .venv\\Scripts\\python.exe scripts\\guanlan_start_hidden.py --no-open # 无窗口启动, 不开浏览器
- .venv\\Scripts\\python.exe scripts\\guanlan_start_hidden.py --wait 180 # 加长等待
- .venv\\Scripts\\pythonw.exe scripts\\guanlan_start_hidden.py --selftest # 只打印"有没有控制台"就退出
- """
- from __future__ import annotations
- import argparse
- import datetime as dt
- import json
- import os
- import pathlib
- import socket
- import subprocess
- import sys
- import time
- import urllib.request
- ROOT = pathlib.Path(__file__).resolve().parents[1]
- sys.path.insert(0, str(ROOT))
- from src import paths as P # noqa: E402
- from src.console import soft # noqa: E402
- soft()
- LOG = P.LOGS / 'start_hidden.log' # 启动器日志: 服务日志命名口径 logs/<组件>.log
- def log(msg: str) -> None:
- """写启动器日志 —— **走统一行格式** `时间戳 级别 组件 消息` (用户令 2 / docs §11.2)。
- 2026-09-17 实测: 这里原来是自己的 `时间戳 消息`(没有级别与组件), log_audit 会报"不符合统一格式";
- 统一后与 6 个服务、运维动作日志一套格式, 排障时一眼能对上。
- """
- from src import logfile as _lf
- P.LOGS.mkdir(parents=True, exist_ok=True)
- with open(LOG, 'a', encoding='utf-8', newline='\n') as f:
- f.write(_lf.line('INFO', 'start_hidden', msg) + '\n')
- def port_up(host: str, port: int, t: float = 0.4) -> bool:
- try:
- with socket.create_connection((host, port), timeout=t):
- return True
- except OSError:
- return False
- def healthz(url: str, timeout=6.0) -> bool:
- try:
- with urllib.request.urlopen(url, timeout=timeout) as r:
- return r.status == 200
- except Exception:
- return False
- def notify(title: str, text: str) -> None:
- """无窗口模式下唯一能让人看见失败的通道 (Windows 消息框; 非 Windows 走 stderr)。"""
- if os.name != 'nt':
- print(f'{title}: {text}', file=sys.stderr)
- return
- try:
- import ctypes
- ctypes.windll.user32.MessageBoxW(0, text, title, 0x30) # MB_ICONWARNING
- except Exception:
- pass
- def console_hwnd() -> int:
- """本进程有没有控制台窗口 (0 = 没有, 即"无窗口"成立)。非 Windows 返回 -1 (不适用)。"""
- if os.name != 'nt':
- return -1
- try:
- import ctypes
- return int(ctypes.windll.kernel32.GetConsoleWindow())
- except Exception:
- return -2
- def main() -> int:
- ap = argparse.ArgumentParser()
- ap.add_argument('--host', default='127.0.0.1')
- ap.add_argument('--gateway', type=int, default=None, help='网关端口 (默认读 configs/serve.json)')
- ap.add_argument('--wait', type=int, default=120, help='等 healthz 就绪的最长秒数')
- ap.add_argument('--no-open', action='store_true', help='就绪后不开浏览器')
- ap.add_argument('--quiet-fail', action='store_true', help='失败时不弹消息框 (只写日志)')
- ap.add_argument('--selftest', action='store_true',
- help='"无窗口"自证: 打印本进程有没有控制台窗口就退出 (给验证用, 不启服务)')
- a = ap.parse_args()
- if a.selftest:
- h = console_hwnd()
- print(json.dumps(dict(console_hwnd=h, windowless=(h == 0),
- interpreter=sys.executable, argv0=sys.argv[0]),
- ensure_ascii=False))
- return 0
- gw = a.gateway
- if gw is None:
- try:
- cfg = json.loads(P.SERVE_JSON.read_text(encoding='utf-8-sig'))
- gw = int(cfg.get('gateway') or 28084)
- except Exception:
- gw = 28084
- url = f'http://{a.host}:{gw}/'
- _h = console_hwnd()
- _w = {0: '无控制台窗口 (= 无窗口启动成立)', -1: '非 Windows (不适用)', -2: '取不到 (视为未知)'}.get(_h, f'有控制台窗口 hwnd={_h}')
- log(f'--- 无窗口启动请求 (端口 {gw}, wait={a.wait}s; 解释器 {sys.executable}) ---')
- log(f'窗口状态: {_w}')
- if port_up(a.host, gw):
- log(f'网关 {gw} 已在运行 → 只打开页面')
- if not a.no_open:
- import webbrowser
- webbrowser.open(url)
- return 0
- py = P.venv_python() or pathlib.Path(sys.executable)
- cmd = [str(py), 'guanlan.py', 'serve']
- flags = 0
- if os.name == 'nt':
- flags = (getattr(subprocess, 'CREATE_NO_WINDOW', 0x08000000)
- | getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0)
- | getattr(subprocess, 'DETACHED_PROCESS', 0))
- P.LOGS.mkdir(parents=True, exist_ok=True)
- lf = open(P.LOGS / 'serve.log', 'ab')
- kw = dict(cwd=str(ROOT), stdout=lf, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
- if os.name == 'nt':
- kw['creationflags'] = flags
- else:
- kw['start_new_session'] = True
- try:
- pid = subprocess.Popen(cmd, **kw).pid
- except Exception as e:
- log(f'✘ 起 serve 失败: {type(e).__name__}: {e}')
- if not a.quiet_fail:
- notify('观澜启动失败', f'无法启动服务: {e}\n详见 logs\\start_hidden.log')
- return 3
- log(f'起 serve pid={pid} (无窗口; 日志 logs/serve.log)')
- t0 = time.time()
- while time.time() - t0 < a.wait:
- if healthz(f'{url}healthz'):
- log(f'✔ 就绪 ({time.time() - t0:.0f}s) → {url}')
- if not a.no_open:
- try:
- import webbrowser
- webbrowser.open(url)
- except Exception as e:
- log(f'⚠ 打开浏览器失败: {e}')
- return 0
- time.sleep(1.5)
- log(f'✘ {a.wait}s 内 healthz 未就绪 (serve pid={pid} 可能已退出)')
- if not a.quiet_fail:
- notify('观澜启动未就绪', f'{a.wait} 秒内网关未就绪, 请查看:\n'
- f'logs\\serve.log\nlogs\\start_hidden.log')
- return 2
- if __name__ == '__main__':
- sys.exit(main())
|