#!/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())