#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
check-web-port — 本地 Web 服务器管理器（单文件版）
================================================
扫描本机所有处于 LISTEN 状态的 TCP 端口，探测其中哪些是 HTTP 服务，
在本地网页面板中展示，支持一键打开 / 关闭。

✅ 单文件、零依赖（仅 Python 标准库）、纯本地运行、不联网。
✅ HTML 面板已内嵌，运行后自动打开浏览器。

用法：
    python3 check-web-port.py            # 默认端口 18443
    python3 check-web-port.py 9000       # 指定端口
或 macOS 双击 start.command 启动。

说明：扫描端口 / 杀进程 / 开浏览器等系统级操作必须由本机程序完成，
浏览器沙箱无法直接做这些事，所以这个 Python 程序无法省略。
"""

import json
import os
import re
import signal
import socket
import subprocess
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse

# ---------------------------------------------------------------------------
# 配置
# ---------------------------------------------------------------------------
DEFAULT_PORT = 18443
HTTP_PROBE_TIMEOUT = 0.4  # 探测 HTTP 时的连接/读取超时（秒）
PROBE_WORKERS = 32        # 并行探测线程数


# ---------------------------------------------------------------------------
# 扫描逻辑
# ---------------------------------------------------------------------------
def list_listeners():
    """用 lsof 获取所有 TCP LISTEN 端口。"""
    try:
        out = subprocess.check_output(
            ["lsof", "-i", "-P", "-n", "-sTCP:LISTEN"],
            stderr=subprocess.DEVNULL,
        ).decode("utf-8", errors="ignore")
    except Exception:
        return []

    results = []
    for line in out.splitlines()[1:]:
        parts = line.split()
        if len(parts) < 9:
            continue
        command = parts[0]
        try:
            pid = int(parts[1])
        except ValueError:
            continue
        user = parts[2]
        name = parts[8]  # 形如 127.0.0.1:3000 / *:3000 / [::1]:3000
        m = re.match(r"^(.+):(\d+)$", name)
        if not m:
            continue
        addr = m.group(1)
        port = int(m.group(2))
        results.append(
            {"command": command, "pid": pid, "user": user, "addr": addr, "port": port}
        )
    return results


def probe_http(host, port, timeout=HTTP_PROBE_TIMEOUT):
    """探测 (host, port) 是否为 HTTP 服务，返回状态行与 Server 头。"""
    sock_host = host
    if host.startswith("[") and host.endswith("]"):
        sock_host = host.strip("[]")
    try:
        s = socket.create_connection((sock_host, port), timeout=timeout)
        s.settimeout(timeout)
        s.sendall(b"HEAD / HTTP/1.0\r\nHost: localhost\r\n\r\n")
        data = s.recv(2048)
        s.close()
    except Exception:
        return None

    text = data.decode("latin-1", errors="ignore")
    if not text.startswith("HTTP/"):
        return {"http": False}
    lines = text.splitlines()
    status_line = lines[0] if lines else ""
    server = ""
    for l in lines:
        if l.lower().startswith("server:"):
            server = l.split(":", 1)[1].strip()
            break
    return {"http": True, "status": status_line, "server": server}


def host_for_probe(addr):
    if addr in ("*", "*:*"):
        return "127.0.0.1"
    if addr == "*":
        return "127.0.0.1"
    return addr


def collect_servers(manager_port, manager_pid):
    """收集所有监听服务，并行探测 HTTP。"""
    listeners = []
    seen = set()
    for l in list_listeners():
        key = (l["pid"], l["port"], l["addr"])
        if key in seen:
            continue
        seen.add(key)
        l["host"] = host_for_probe(l["addr"])
        listeners.append(l)

    def probe_one(l):
        return (l, probe_http(l["host"], l["port"]))

    probe_results = {}
    with ThreadPoolExecutor(max_workers=PROBE_WORKERS) as ex:
        for l, probe in ex.map(probe_one, listeners):
            probe_results[(l["pid"], l["port"], l["addr"])] = probe

    servers = []
    for l in listeners:
        probe = probe_results.get((l["pid"], l["port"], l["addr"]))
        is_web = bool(probe and probe.get("http"))
        host = l["host"]
        servers.append(
            {
                "command": l["command"],
                "pid": l["pid"],
                "user": l["user"],
                "addr": l["addr"],
                "port": l["port"],
                "host": host,
                "is_web": is_web,
                "server_header": probe.get("server", "") if probe else "",
                "status_line": probe.get("status", "") if probe else "",
                "url": f"http://{host}:{l['port']}",
                "is_self": (l["port"] == manager_port and l["pid"] == manager_pid),
            }
        )
    servers.sort(key=lambda s: (not s["is_web"], s["port"]))
    return servers


# ---------------------------------------------------------------------------
# 操作
# ---------------------------------------------------------------------------
def open_in_browser(url):
    try:
        if sys.platform == "darwin":
            subprocess.run(["open", url], check=False)
        elif os.name == "nt":
            os.startfile(url)  # type: ignore[attr-defined]
        else:
            subprocess.run(["xdg-open", url], check=False)
        return True, ""
    except Exception as e:
        return False, str(e)


def kill_process(pid):
    if pid <= 0:
        return False, "无效的 PID"
    try:
        os.kill(pid, signal.SIGTERM)
        return True, f"已向 PID {pid} 发送终止信号"
    except ProcessLookupError:
        return False, f"进程 {pid} 不存在"
    except PermissionError:
        try:
            subprocess.run(
                ["sudo", "-n", "kill", str(pid)],
                check=True, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
            )
            return True, f"已通过 sudo 终止 PID {pid}"
        except Exception as e:
            return False, f"无权限终止 PID {pid}：{e}"
    except Exception as e:
        return False, f"终止 PID {pid} 失败：{e}"


# ---------------------------------------------------------------------------
# 内嵌前端页面（原始字符串，保留 JS 中的 \n 等转义）
# ---------------------------------------------------------------------------
INDEX_HTML = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>本地 Web 服务器管理器</title>
<style>
  :root {
    --bg: #0f1419; --card: #1a2029; --card-hover: #222b36;
    --border: #2a3441; --text: #e6e6e6; --muted: #8a96a6;
    --accent: #4a9eff; --green: #34d399; --red: #f87171;
    --amber: #fbbf24;
  }
  * { box-sizing: border-box; }
  body {
    margin: 0; font-family: -apple-system, "PingFang SC", "Segoe UI", sans-serif;
    background: var(--bg); color: var(--text); min-height: 100vh;
  }
  header {
    padding: 18px 28px; border-bottom: 1px solid var(--border);
    display: flex; align-items: center; justify-content: space-between;
    position: sticky; top: 0; background: var(--bg); z-index: 10; gap: 14px;
    flex-wrap: wrap;
  }
  header h1 { font-size: 18px; margin: 0; font-weight: 600; }
  header .sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
  .toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
  .stats { color: var(--muted); font-size: 13px; }
  .stats b { color: var(--text); }
  main { max-width: 980px; margin: 0 auto; padding: 18px 20px 60px; }
  .conn-bar {
    display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
    background: var(--card); border: 1px solid var(--border); border-radius: 10px;
    padding: 10px 14px; margin-bottom: 16px; font-size: 13px;
  }
  .conn-bar .dot {
    width: 9px; height: 9px; border-radius: 50%; background: var(--muted);
    flex-shrink: 0;
  }
  .conn-bar .dot.ok { background: var(--green); box-shadow: 0 0 6px var(--green); }
  .conn-bar .dot.err { background: var(--red); }
  .conn-bar .dot.wait { background: var(--amber); }
  .conn-bar label { color: var(--muted); }
  .conn-bar input {
    background: var(--bg); border: 1px solid var(--border); color: var(--text);
    padding: 6px 10px; border-radius: 6px; font-size: 13px; width: 240px;
    font-family: "SF Mono", Menlo, monospace;
  }
  .conn-bar input:focus { outline: none; border-color: var(--accent); }
  .conn-bar .hint { color: var(--muted); font-size: 11px; flex-basis: 100%; }
  button {
    background: var(--card); color: var(--text); border: 1px solid var(--border);
    padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
    transition: all .15s; display: inline-flex; align-items: center; gap: 6px;
  }
  button:hover { background: var(--card-hover); border-color: var(--accent); }
  button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  button.primary:hover { filter: brightness(1.08); }
  button.danger { color: var(--red); border-color: var(--border); }
  button.danger:hover { background: rgba(248,113,113,.12); border-color: var(--red); }
  button.small { padding: 5px 10px; font-size: 12px; }
  .grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
  .card {
    background: var(--card); border: 1px solid var(--border); border-radius: 12px;
    padding: 16px 18px; transition: all .15s;
  }
  .card:hover { border-color: var(--border); background: var(--card-hover); }
  .card.web { border-left: 3px solid var(--green); }
  .card.non-web { border-left: 3px solid var(--muted); opacity: .92; }
  .card.self { border-left-color: var(--amber); }
  .row1 { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
  .title { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
  .badge {
    font-size: 11px; padding: 2px 7px; border-radius: 10px; font-weight: 500;
    border: 1px solid var(--border);
  }
  .badge.web { color: var(--green); border-color: rgba(52,211,153,.4); background: rgba(52,211,153,.08); }
  .badge.tcp { color: var(--muted); }
  .badge.self { color: var(--amber); border-color: rgba(251,191,36,.4); background: rgba(251,191,36,.08); }
  .meta { color: var(--muted); font-size: 12px; margin-top: 6px; display: flex; flex-wrap: wrap; gap: 14px; }
  .meta code { color: var(--text); background: rgba(255,255,255,.05); padding: 1px 6px; border-radius: 4px; }
  .url { color: var(--accent); font-family: "SF Mono", Menlo, monospace; }
  .server-tag { color: var(--green); font-family: "SF Mono", Menlo, monospace; font-size: 12px; }
  .status-tag { color: var(--muted); font-family: "SF Mono", Menlo, monospace; font-size: 12px; }
  .btns { display: flex; gap: 8px; flex-shrink: 0; }
  .empty {
    text-align: center; padding: 60px 20px; color: var(--muted);
    border: 1px dashed var(--border); border-radius: 12px;
  }
  .loading { text-align: center; padding: 30px; color: var(--muted); }
  .toast {
    position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
    background: var(--card); border: 1px solid var(--border); color: var(--text);
    padding: 10px 18px; border-radius: 8px; font-size: 13px;
    box-shadow: 0 8px 24px rgba(0,0,0,.4); opacity: 0; transition: opacity .25s;
    pointer-events: none; z-index: 100;
  }
  .toast.show { opacity: 1; }
  .toast.err { border-color: var(--red); color: var(--red); }
  .toast.ok { border-color: var(--green); color: var(--green); }
  .spin { display:inline-block; width:12px; height:12px; border:2px solid var(--muted);
    border-top-color: transparent; border-radius:50%; animation: spin .8s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }
  label.switch { display:flex; align-items:center; gap:6px; font-size:13px; color:var(--muted); cursor:pointer; }
</style>
</head>
<body>
<header>
  <div>
    <h1>本地 Web 服务器管理器</h1>
    <div class="sub">扫描本机 LISTEN 端口 · 探测 HTTP · 一键打开 / 关闭</div>
  </div>
  <div class="toolbar">
    <div class="stats" id="stats">-</div>
    <label class="switch">
      <input type="checkbox" id="autoRefresh" checked>
      <span>自动刷新</span>
    </label>
    <button class="primary" onclick="refresh(true)">⟳ 刷新</button>
  </div>
</header>

<main>
  <div class="conn-bar">
    <span class="dot" id="connDot"></span>
    <span id="connText">未连接</span>
    <label>后端地址：</label>
    <input id="apiBaseInput" placeholder="http://localhost:18443">
    <button class="small primary" onclick="saveApiBase()">保存</button>
    <button class="small" onclick="refresh(true)">测试连接</button>
    <span class="hint">提示：本地运行时地址留空即可（同源）。若用云端页面，则填本机后端地址。</span>
  </div>

  <div class="grid" id="list">
    <div class="loading"><span class="spin"></span> 正在扫描本机端口…</div>
  </div>
</main>

<div class="toast" id="toast"></div>

<script>
let lastServers = [];
let timer = null;

// ---- 可配置的后端地址 ----
function defaultApiBase() {
  // 本地嵌入页（后端直接提供页面）→ 同源
  if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {
    return '';
  }
  // 云端部署 → 默认连本机后端
  return 'http://localhost:18443';
}
function apiBase() {
  const v = localStorage.getItem('apiBase');
  return v === null ? defaultApiBase() : v;
}
function api(path) { return apiBase() + path; }
function setConn(state, text) {
  const dot = document.getElementById('connDot');
  const tx = document.getElementById('connText');
  dot.className = 'dot ' + state; // '' | ok | err | wait
  tx.textContent = text;
}

function toast(msg, kind) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.className = 'toast show ' + (kind || '');
  clearTimeout(t._tm);
  t._tm = setTimeout(() => t.className = 'toast ' + (kind || ''), 2200);
}

function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
    ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}

async function refresh(showSpin) {
  const list = document.getElementById('list');
  if (showSpin) {
    list.innerHTML = '<div class="loading"><span class="spin"></span> 正在扫描本机端口…</div>';
  }
  setConn('wait', '连接中…');
  try {
    const r = await fetch(api('/api/servers?_=' + Date.now()));
    const data = await r.json();
    lastServers = data.servers || [];
    render(lastServers);
    setConn('ok', '已连接 ' + (apiBase() || location.origin));
  } catch (e) {
    setConn('err', '连接失败');
    list.innerHTML = '<div class="empty">无法连接后端：' + esc(e.message) + '<br><br>' +
      '请确认本机后端程序正在运行，且下方地址正确。</div>';
  }
}

function render(servers) {
  const list = document.getElementById('list');
  const stats = document.getElementById('stats');
  const webCount = servers.filter(s => s.is_web).length;
  stats.innerHTML = '共 <b>' + servers.length + '</b> 个监听 · 其中 <b>' + webCount + '</b> 个 Web 服务';

  if (!servers.length) {
    list.innerHTML = '<div class="empty">没有发现任何处于监听状态的端口。<br>启动一个 Web 服务后再刷新试试。</div>';
    return;
  }

  list.innerHTML = servers.map(s => {
    const cls = s.is_self ? 'self' : (s.is_web ? 'web' : 'non-web');
    const badge = s.is_self
      ? '<span class="badge self">管理面板</span>'
      : (s.is_web ? '<span class="badge web">HTTP</span>' : '<span class="badge tcp">TCP</span>');
    const serverTag = s.server_header ? '<span class="server-tag">' + esc(s.server_header) + '</span>' : '';
    const statusTag = s.status_line ? '<span class="status-tag">' + esc(s.status_line) + '</span>' : '';
    const openBtn = s.is_web
      ? `<button class="small primary js-open" data-host="${esc(s.host)}" data-port="${s.port}">打开</button>`
      : `<button class="small js-open" data-host="${esc(s.host)}" data-port="${s.port}">尝试打开</button>`;
    const killBtn = s.is_self
      ? `<button class="small danger js-kill-self">关闭面板</button>`
      : `<button class="small danger js-kill" data-pid="${s.pid}" data-cmd="${esc(s.command)}">关闭</button>`;
    return (
      '<div class="card ' + cls + '">' +
        '<div class="row1">' +
          '<div>' +
            '<div class="title">' + esc(s.command) + ' ' + badge + '</div>' +
            '<div class="meta">' +
              '<span>PID: <code>' + s.pid + '</code></span>' +
              '<span>端口: <code>' + s.port + '</code></span>' +
              '<span>地址: <code>' + esc(s.addr) + '</code></span>' +
              (s.user ? '<span>用户: <code>' + esc(s.user) + '</code></span>' : '') +
            '</div>' +
            (s.is_web || serverTag || statusTag
              ? '<div class="meta">' +
                  '<span class="url">' + esc(s.url) + '</span>' +
                  (serverTag ? ' · ' + serverTag : '') +
                  (statusTag ? ' · ' + statusTag : '') +
                '</div>'
              : '<div class="meta"><span class="url">' + esc(s.url) + '</span> · 非 HTTP 服务</div>') +
          '</div>' +
          '<div class="btns">' + openBtn + killBtn + '</div>' +
        '</div>' +
      '</div>'
    );
  }).join('');
}

// 事件委托：处理动态生成的按钮
document.getElementById('list').addEventListener('click', e => {
  const btn = e.target.closest('button');
  if (!btn) return;
  if (btn.classList.contains('js-open')) openUrl(btn.dataset.port, btn.dataset.host);
  else if (btn.classList.contains('js-kill')) killPid(btn.dataset.pid, btn.dataset.cmd);
  else if (btn.classList.contains('js-kill-self')) killSelf();
});

async function openUrl(port, host) {
  const url = 'http://' + host + ':' + port;
  try {
    const r = await fetch(api('/api/open'), {method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({url: url})});
    const d = await r.json();
    if (d.ok) toast('已在浏览器打开 ' + url, 'ok');
    else toast('打开失败：' + (d.error || '未知错误'), 'err');
  } catch (e) { toast('打开失败：' + e.message, 'err'); }
}

async function killPid(pid, name) {
  if (!confirm('确定要关闭进程 "' + name + '" (PID ' + pid + ') 吗？\n这会终止该服务。')) return;
  try {
    const r = await fetch(api('/api/kill'), {method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({pid: pid})});
    const d = await r.json();
    if (d.ok) { toast(d.message || '已关闭', 'ok'); setTimeout(() => refresh(false), 400); }
    else toast(d.error || '关闭失败', 'err');
  } catch (e) { toast('关闭失败：' + e.message, 'err'); }
}

async function killSelf() {
  if (!confirm('确定要关闭本管理面板吗？\n关闭后需要重新运行程序才能再次访问。')) return;
  try {
    await fetch(api('/api/kill'), {method:'POST', headers:{'Content-Type':'application/json'},
      body: JSON.stringify({pid: 'self'})});
  } catch (e) {}
  toast('面板即将关闭…', 'ok');
  setTimeout(() => { document.body.innerHTML = '<div class="empty" style="margin-top:80px">面板已关闭。</div>'; }, 800);
}

function saveApiBase() {
  let v = document.getElementById('apiBaseInput').value.trim();
  if (v && !/^https?:\/\//.test(v)) v = 'http://' + v;
  if (v.endsWith('/')) v = v.slice(0, -1);
  localStorage.setItem('apiBase', v);
  toast('已保存后端地址：' + (v || '同源'), 'ok');
  refresh(true);
}

// 自动刷新
function setupTimer() {
  const cb = document.getElementById('autoRefresh');
  function apply() {
    clearInterval(timer); timer = null;
    if (cb.checked) { timer = setInterval(() => refresh(false), 4000); }
  }
  cb.addEventListener('change', apply);
  apply();
}

// 初始化
document.getElementById('apiBaseInput').value = apiBase() || '';
refresh(true);
setupTimer();
</script>
</body>
</html>
"""


# ---------------------------------------------------------------------------
# HTTP 服务
# ---------------------------------------------------------------------------
class Handler(BaseHTTPRequestHandler):
    manager_port = DEFAULT_PORT
    manager_pid = os.getpid()

    def log_message(self, *args):  # 静默
        pass

    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.send_header("Content-Length", "0")
        self.end_headers()

    def _json(self, obj, code=200):
        body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self._cors()
        self.end_headers()
        self.wfile.write(body)

    def _html(self, text):
        body = text.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self._cors()
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/":
            self._html(INDEX_HTML)
        elif parsed.path == "/api/servers":
            self._json({"ok": True, "servers": collect_servers(self.manager_port, self.manager_pid)})
        else:
            self._json({"ok": False, "error": "not found"}, 404)

    def do_POST(self):
        parsed = urlparse(self.path)
        length = int(self.headers.get("Content-Length", 0) or 0)
        raw = self.rfile.read(length) if length else b""
        try:
            payload = json.loads(raw.decode("utf-8")) if raw else {}
        except Exception:
            payload = {}

        if parsed.path == "/api/open":
            url = payload.get("url")
            if not url:
                self._json({"ok": False, "error": "缺少 url"}, 400)
                return
            ok, err = open_in_browser(url)
            self._json({"ok": ok, "error": err})
        elif parsed.path == "/api/kill":
            pid = payload.get("pid")
            if pid in ("self", None):
                pid_val = self.manager_pid
            else:
                try:
                    pid_val = int(pid)
                except (TypeError, ValueError):
                    self._json({"ok": False, "error": "无效的 pid"}, 400)
                    return
            ok, msg = kill_process(pid_val)
            if ok:
                self._json({"ok": True, "message": msg})
                if pid_val == self.manager_pid:
                    threading.Timer(0.4, lambda: os._exit(0)).start()
            else:
                self._json({"ok": False, "error": msg})
        else:
            self._json({"ok": False, "error": "not found"}, 404)


def find_free_port(preferred):
    for port in range(preferred, preferred + 50):
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind(("127.0.0.1", port))
                return port
            except OSError:
                continue
    return preferred


def main():
    port = DEFAULT_PORT
    if len(sys.argv) > 1:
        try:
            port = int(sys.argv[1])
        except ValueError:
            print("参数应为端口号，例如: python3 check-web-port.py 9000")
            sys.exit(1)

    port = find_free_port(port)
    Handler.manager_port = port
    Handler.manager_pid = os.getpid()

    server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
    url = f"http://localhost:{port}"
    print("=" * 56)
    print("  check-web-port · 本地 Web 服务器管理器")
    print(f"  面板地址: {url}")
    print("  浏览器将自动打开，纯本地运行，不联网")
    print("  按 Ctrl+C 退出")
    print("=" * 56)

    threading.Timer(0.6, lambda: open_in_browser(url)).start()

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n已退出。")
    finally:
        server.server_close()


if __name__ == "__main__":
    main()
