#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
open_chrome.py —— 跨平台（macOS/Linux/Windows）弹出 palm-kyc-community 的 Chrome 验证小窗

用法：
  python3 open_chrome.py register [W H X Y] [--session=<id>] [--launcher=<auto|spawn|apple|manual>]
  python3 open_chrome.py verify   [W H X Y] [--session=<id>] [--launcher=<auto|spawn|apple|manual>]

launcher 启动策略（默认 auto，推荐）：
  auto    自动选择。macOS 受限 TCC 上下文（Electron 客户端 / qclaw 沙箱等）
          → apple；其他场景一律 spawn。
          可通过 PALM_FORCE_SPAWN=1 强制走 spawn（适用于 IDE 自身已被授予摄像头权限的场景，0 弹窗）。
  spawn   直接 Popen 一个独立 user-data-dir 的 Chrome，--app 纯净小窗（需本机 Chrome）。
  apple   macOS：优先用 `open -na "Google Chrome" --args --profile-directory=<last_used> <url>`
          让用户已运行的 Chrome 在"上次使用过的 profile"下新开窗口（profile 锁定，
          站点摄像头权限可在多次调用间稳定复用）；失败回退到 AppleScript 在当前激活 profile
          上开窗。避开"父进程受限 TCC 归因"的摄像头黑屏问题。
  manual  仅启 HTTP server 并打印 URL，让用户手动用本机 Chrome 打开（最后兜底）。

行为：
  - 自动探测本机 Chrome 可执行文件路径（可通过环境变量 CHROME_BIN 覆盖，spawn 模式需要）
  - spawn 模式：独立 user-data-dir（默认 ~/.cache/palm-register-chrome；CHROME_USER_DIR 可覆盖）
          幂等初始化摄像头/麦克风白名单（复用 init-chrome-prefs.py）
          启动前从 creds.py 读取 Authorization，服务器代理注入到后端请求 header
  - 内嵌本地 HTTP 服务器（127.0.0.1:51823，被占用时降级随机端口），提供静态资源代理、
    后端接口代理、结果接收、控制 endpoint。
    选择固定端口是为了让 Chrome 站点摄像头授权能在多次调用间复用（同一 origin）。
"""
import datetime
import json
import os
import platform
import random
import shutil
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.request
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse

HERE = os.path.dirname(os.path.abspath(__file__))
SKILL_DIR = os.path.dirname(HERE)

# 跳过 SSL 证书校验：
# 掌纹核验后端为内网自签/企业 CA 场景，且本地 Python 运行环境经常缺失系统根证书
# （macOS 独立 Python 安装包若未执行 Install Certificates.command 会触发
# CERTIFICATE_VERIFY_FAILED）。由于请求完全由本脚本自身发起、目标固定为掌纹识别
# 官方域名，不存在 MITM 风险，这里统一走未校验 SSL 上下文，避免给使用方增加额外
# 证书安装步骤。
_UNVERIFIED_SSL_CTX = ssl._create_unverified_context()


def _real_home() -> str:
    """获取真实用户家目录。

    某些 macOS 客户端（App Sandbox 应用等）会把 $HOME 重写到
    ~/Library/Containers/<bundle-id>/Data 下。通过 pwd 直接查系统用户数据库，
    可绕过 $HOME 重写，拿到真实家目录，避免 init-chrome-prefs 写入的白名单
    Chrome 读不到。
    """
    try:
        import pwd
        return pwd.getpwuid(os.getuid()).pw_dir
    except Exception:
        return os.environ.get("HOME", "") or os.path.expanduser("~")


# 日志文件放在用户家目录下，避免 AI 在 skill 目录看到 .log 文件而误读为"结果"。
# 日志仅供人工 debug，AI 严禁读取；最终结果只通过 stdout 的 [open_chrome] RESULT: 行返回。
# 使用真实 HOME：沙箱内外的调用看到的是同一份日志，便于排查。
_LOG_DIR = os.path.join(_real_home(), ".palm-kyc-community")
try:
    os.makedirs(_LOG_DIR, exist_ok=True)
except Exception:
    _LOG_DIR = SKILL_DIR  # 兜底
LOG_FILE = os.path.join(_LOG_DIR, "palm-debug.log")
INIT_PREFS = os.path.join(HERE, "init-chrome-prefs.py")
CREDS_PY = os.path.join(HERE, "creds.py")


# 日志保留天数：仅保留最近 N 天的日志，避免文件无限膨胀
_LOG_RETENTION_DAYS = 2


def _trim_log_file(path: str, retention_days: int = _LOG_RETENTION_DAYS) -> None:
    """启动时调用：按行解析日志中的 [YYYY-MM-DD HH:MM:SS.sss] 前缀时间戳，
    丢弃 retention_days 天前的所有日志行（含其后续无前缀的续行）；当前日及最近
    retention_days-1 天的日志全部保留。失败时静默忽略，绝不影响主流程。
    """
    try:
        if not os.path.exists(path):
            return
        # 文件不大就不处理，避免每次启动都做无谓 IO（< 64KB 直接跳过）
        try:
            if os.path.getsize(path) < 64 * 1024:
                return
        except Exception:
            pass

        cutoff = datetime.datetime.now() - datetime.timedelta(days=retention_days)
        kept_lines = []
        keep_current = False  # 当前行属于"保留段"还是"丢弃段"

        with open(path, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                # 形如 "[2026-05-20 17:26:54.123] {...}"
                if len(line) >= 25 and line[0] == "[" and line[20:22] in (".0", ".1", ".2", ".3", ".4", ".5", ".6", ".7", ".8", ".9"):
                    try:
                        line_ts = datetime.datetime.strptime(line[1:24], "%Y-%m-%d %H:%M:%S.%f")
                        keep_current = (line_ts >= cutoff)
                    except Exception:
                        # 解析失败的行沿用上一段的保留状态
                        pass
                # 没有时间戳前缀的行（多行日志续行）沿用上一段状态
                if keep_current:
                    kept_lines.append(line)

        # 用临时文件原子替换，避免半截写入
        tmp_path = path + ".trim.tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            f.writelines(kept_lines)
        os.replace(tmp_path, path)
    except Exception:
        # 任何异常都不影响主流程
        try:
            if os.path.exists(path + ".trim.tmp"):
                os.remove(path + ".trim.tmp")
        except Exception:
            pass


# 启动时清理一次过期日志
_trim_log_file(LOG_FILE)


def detect_chrome_bin() -> str:
    env = os.environ.get("CHROME_BIN")
    if env and os.path.exists(env):
        return env

    sysname = platform.system()
    candidates = []
    if sysname == "Darwin":
        candidates = [
            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
            "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
        ]
    elif sysname == "Windows":
        pf = os.environ.get("ProgramFiles", r"C:\Program Files")
        pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
        local = os.environ.get("LOCALAPPDATA", os.path.expanduser(r"~\AppData\Local"))
        candidates = [
            os.path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
            os.path.join(pf86, "Google", "Chrome", "Application", "chrome.exe"),
            os.path.join(local, "Google", "Chrome", "Application", "chrome.exe"),
        ]
    else:  # Linux / *BSD
        for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
            p = shutil.which(name)
            if p:
                candidates.append(p)
        candidates += [
            "/usr/bin/google-chrome",
            "/usr/bin/chromium",
            "/usr/bin/chromium-browser",
            "/snap/bin/chromium",
        ]

    for c in candidates:
        if c and os.path.exists(c):
            return c
    return ""


def default_user_dir() -> str:
    env = os.environ.get("CHROME_USER_DIR")
    if env:
        return env
    # macOS 上某些客户端（App Sandbox 应用等）会把 $HOME 重写到 Containers 目录，
    # 与 Chrome 看到的 $HOME 可能不同，导致 init-chrome-prefs 写入的摄像头白名单
    # Chrome 读不到 → 摄像头黑屏。因此 macOS 下统一用 pwd 取真实家目录；
    # 其他平台维持 expanduser("~") 行为。
    if platform.system() == "Darwin":
        home = _real_home()
    else:
        home = os.path.expanduser("~")
    # 三端统一放到 ~/.cache/palm-register-chrome
    return os.path.join(home, ".cache", "palm-register-chrome")


def _pgrep(pattern: str, exclude_pid: int = 0) -> list:
    """按命令行 pattern 查找进程 PID（仅 *nix）。"""
    try:
        out = subprocess.run(
            ["pgrep", "-f", pattern],
            capture_output=True, text=True, check=False,
        )
        pids = []
        for line in out.stdout.strip().splitlines():
            line = line.strip()
            if not line:
                continue
            try:
                pid = int(line)
            except ValueError:
                continue
            if pid and pid != exclude_pid:
                pids.append(pid)
        return pids
    except Exception:
        return []


def _kill_pids(pids: list, sig: str) -> None:
    """向指定 PID 列表发送信号（sig 形如 '-TERM' / '-9'）。"""
    for pid in pids:
        try:
            subprocess.run(
                ["kill", sig, str(pid)],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
            )
        except Exception:
            pass

def kill_prev(user_dir: str) -> None:
    """启动前强制清理残留进程：先检测，有就先 TERM、再 KILL -9，并清理 Singleton 锁。

    目的：避免上一次 Chrome 非正常退出留下的进程/锁导致新实例静默退出、弹不出窗。
    安全边界：只杀同 user_dir 匹配的 Chrome 实例（即本 skill 自己起的专属 Profile），
    绝不误伤用户日常 Chrome（日常 Chrome 用 ~/Library/Application Support/Google/Chrome）。
    """
    sysname = platform.system()
    script_name = os.path.basename(__file__)  # open_chrome.py
    script_abs = os.path.abspath(__file__)
    my_pid = os.getpid()

    try:
        if sysname == "Windows":
            user_dir_escaped = user_dir.replace("'", "''")
            script_name_escaped = script_name.replace("'", "''")

            # 先检测残留 Chrome 数量
            detect = subprocess.run(
                ["powershell", "-NoProfile", "-Command",
                 f"(Get-Process chrome -ErrorAction SilentlyContinue | "
                 f"Where-Object {{ $_.CommandLine -like '*{user_dir_escaped}*' }} | "
                 f"Measure-Object).Count"],
                capture_output=True, text=True, check=False,
            )
            count = (detect.stdout or "0").strip()
            if count and count != "0":
                print(f"[open_chrome] detected {count} stale Chrome process(es), killing...")
                sys.stdout.flush()
                # Stop-Process -Force 在 Windows 上相当于 kill -9
                subprocess.run(
                    ["powershell", "-NoProfile", "-Command",
                     f"Get-Process chrome -ErrorAction SilentlyContinue | "
                     f"Where-Object {{ $_.CommandLine -like '*{user_dir_escaped}*' }} | "
                     f"Stop-Process -Force"],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
                )

            # 杀残留 open_chrome.py（排除自身）
            subprocess.run(
                ["powershell", "-NoProfile", "-Command",
                 f"Get-Process python,python3 -ErrorAction SilentlyContinue | "
                 f"Where-Object {{ $_.Id -ne {my_pid} -and $_.CommandLine -like '*{script_name_escaped}*' }} | "
                 f"Stop-Process -Force"],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
            )
        else:
            # ── 1) 残留 Chrome（按 user_dir 匹配） ──
            # 按 user_dir 精确查杀：
            #   _pgrep 用完整 user_dir 路径匹配命令行，只命中本 skill 自己起的 Chrome 实例，
            #   不会误伤用户日常 Chrome（日常 Chrome 用 ~/Library/Application Support/Google/Chrome）。
            chrome_pids = _pgrep(user_dir)
            if chrome_pids:
                print(f"[open_chrome] detected {len(chrome_pids)} stale Chrome process(es): {chrome_pids}, killing...")
                sys.stdout.flush()
                # 先优雅终止
                _kill_pids(chrome_pids, "-TERM")
                # 等待最多 1.5s
                for _ in range(15):
                    time.sleep(0.1)
                    if not _pgrep(user_dir):
                        break
                # 仍然存活则 -9 强杀
                remaining = _pgrep(user_dir)
                if remaining:
                    print(f"[open_chrome] {len(remaining)} process(es) still alive after TERM, sending KILL -9: {remaining}")
                    sys.stdout.flush()
                    _kill_pids(remaining, "-9")
                    # 再等一小段
                    for _ in range(10):
                        time.sleep(0.1)
                        if not _pgrep(user_dir):
                            break

            # ── 2) 残留 open_chrome.py（排除自身 PID，避免误杀当前进程） ──
            py_pids = _pgrep(script_abs, exclude_pid=my_pid)
            if py_pids:
                print(f"[open_chrome] detected {len(py_pids)} stale open_chrome.py process(es): {py_pids}, killing...")
                sys.stdout.flush()
                _kill_pids(py_pids, "-TERM")
                for _ in range(10):
                    time.sleep(0.1)
                    if not _pgrep(script_abs, exclude_pid=my_pid):
                        break
                remaining_py = _pgrep(script_abs, exclude_pid=my_pid)
                if remaining_py:
                    _kill_pids(remaining_py, "-9")

        # ── 3) 清理 Singleton 锁（Chrome 非正常退出会残留） ──
        # 沙箱 / 非沙箱统一清理：上一步已经把同 user_dir 的 Chrome 全部杀干净，
        # 残留的 SingletonLock/Cookie/Socket 是"僵尸锁"，不删会阻止新 Chrome 启动或
        # 诱导 `open -a` 去复用已不存在的实例，表现为小窗弹不出或立即退出。
        for lock_name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
            try:
                os.unlink(os.path.join(user_dir, lock_name))
            except OSError:
                pass
    except Exception as e:
        print(f"[open_chrome] WARN: kill_prev error: {e}", file=sys.stderr)
    time.sleep(0.3)


def patch_prefs(user_dir: str) -> None:
    if not os.path.exists(INIT_PREFS):
        return
    try:
        subprocess.run(
            [sys.executable, INIT_PREFS, user_dir],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
        )
    except Exception as e:
        print(f"[open_chrome] WARN: init-chrome-prefs failed: {e}", file=sys.stderr)


def read_cred(key: str) -> str:
    """从 creds.py 读取指定凭证；读不到返回空字符串。"""
    if not os.path.exists(CREDS_PY):
        return ""
    try:
        out = subprocess.run(
            [sys.executable, CREDS_PY, "get", key],
            capture_output=True, text=True, check=False,
        )
        if out.returncode != 0:
            print(f"[open_chrome] WARN: '{key}' not configured. "
                  f"Run: python3 {CREDS_PY} set {key}",
                  file=sys.stderr)
            return ""
        return out.stdout.strip()
    except Exception as e:
        print(f"[open_chrome] WARN: read '{key}' failed: {e}", file=sys.stderr)
        return ""


def find_free_port() -> int:
    """随机找一个可用的本地端口。"""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


# 当前会话 session_id（在 launch() 中赋值），AppleScript 关 tab 时按 session 精确匹配本次启动的窗口，
# 避免端口降级导致匹配失败、也避免误关用户其它 127.0.0.1 tab
_CURRENT_SESSION_ID: str = ""

# 固定端口候选集：避免端口随每次启动变化导致 Chrome 站点摄像头授权失效
# （Chrome 站点权限按 scheme+host+port 隔离，端口变了就是新 origin → 重新申请摄像头授权）。
#
# 设计说明：
#   - 单一固定端口在同机并发或上次监听未释放时容易冲突，一旦冲突就会回退到完全随机端口，
#     用户每次都要重新授权摄像头，体验很差。
#   - 因此改用一段连续的候选端口（10 个），依次尝试 bind，命中的第一个就用。
#   - 候选端口数量有限（最多 10 个 origin），用户对每个 origin 授权一次后，
#     后续无论命中哪个候选都不会再弹权限。
#   - 所有候选端口都被占用时才回退到完全随机端口（带 stderr 警告）。
FIXED_PORT = 51823
FIXED_PORT_CANDIDATES = list(range(FIXED_PORT, FIXED_PORT + 10))  # 51823 ~ 51832


def _try_bind(port: int) -> bool:
    """尝试在 127.0.0.1:port 上 bind，成功返回 True。

    重要：这里必须设 SO_REUSEADDR，与下面真正启动的 HTTPServer (allow_reuse_address=True)
    保持一致。否则会出现一个隐蔽 bug：
      - 上一次脚本退出后，端口进入 TIME_WAIT 状态，需 30~60s 才释放；
      - 本函数不带 SO_REUSEADDR 时，此间隔内 _try_bind(51823) 会返回 False；
      - pick_port() 退化到 51824 → origin 变为 http://127.0.0.1:51824 →
        Chrome 认为是新站点，摄像头权限会被重新询问。
      加上 SO_REUSEADDR 后，TIME_WAIT 状态的端口可被重新 bind，端口不会飘，权限不会重弹。
    """
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            s.bind(("127.0.0.1", port))
        return True
    except OSError:
        return False


def pick_port() -> int:
    """优先在固定候选端口集合中挑选；全部被占时降级随机端口。"""
    for p in FIXED_PORT_CANDIDATES:
        if _try_bind(p):
            if p == FIXED_PORT_CANDIDATES[0]:
                print(f"[open_chrome] using fixed port {p} (camera permission stable)", file=sys.stderr)
            else:
                print(
                    f"[open_chrome] using fixed port {p} (primary {FIXED_PORT_CANDIDATES[0]} busy, "
                    f"still within fixed candidate set, no permission re-prompt expected)",
                    file=sys.stderr,
                )
            return p
    port = find_free_port()
    print(
        f"[open_chrome] WARN: all fixed ports {FIXED_PORT_CANDIDATES[0]}-"
        f"{FIXED_PORT_CANDIDATES[-1]} occupied, fallback to {port}",
        file=sys.stderr,
    )
    print(
        f"[open_chrome]       Chrome will request camera permission again for this new origin",
        file=sys.stderr,
    )
    return port


def detect_restricted_tcc() -> tuple:
    """判定当前是否运行在受限 TCC 上下文。

    本脚本仅在 AI agent / IDE 插件场景中被调用，由当前进程 Popen 出来的 Chrome 子进程
    会继承宿主的 TCC 归因（VSCode / JetBrains / Electron 客户端等），导致摄像头黑屏。
    因此 macOS 上默认视为受限，强制走 AppleScript 启动器让用户已运行的 Chrome 主进程
    开窗（TCC 归因到 Chrome 自己，复用用户已授予的系统权限）。

    其他平台不存在 TCC 归因模型，恒返回 (False, "")。

    如需在原生 Terminal / iTerm 中调试 spawn 路径，可显式传入 --launcher=spawn；
    或设置环境变量 PALM_FORCE_SPAWN=1 强制走 spawn 模式（适用于 IDE 自身已被授予
    macOS 摄像头权限的场景，0 点击 0 弹窗）。
    """
    if platform.system() != "Darwin":
        return (False, "")
    if os.environ.get("PALM_FORCE_SPAWN", "").strip() in ("1", "true", "True", "yes"):
        return (False, "PALM_FORCE_SPAWN=1")
    return (True, "ai-agent (macOS)")


# 全局保存凭证，供代理接口使用
# token：JWT，以 Authorization: Bearer <token> 形式传递；userid：用于 compare 接口请求体中的 CompareUserId 字段
_PALM_TOKEN: str = ""
_PALM_USERID: str = ""

# 内存结果存储：session_id -> result dict（供 AI 轮询）
_RESULTS: dict = {}
_RESULTS_LOCK = threading.Lock()

# 最近一次代理请求生成的 X-TraceId，按 session_id 维度记录。
# 一次注册/识别会话可能触发多次后端请求（重试 / verify 阶段多次比对），
# 这里只保留最近一次的 traceId —— 它对应"产生最终结果"的那次请求。
# 结果回传时会自动合并到 RESULT JSON 的 trace_id 字段，方便 Agent 直接展示。
_TRACE_IDS: dict = {}
_TRACE_IDS_LOCK = threading.Lock()


def _gen_trace_id() -> str:
    """生成 32 位全小写十六进制 trace id，例如 5f2cbde1dd61be666fd1b3d7edbd59cc。

    要求：
      - 长度 32（16 字节随机熵）
      - 字符集 [0-9a-f]（全小写，便于在日志、URL、命令行中复制时不会因大小写差异引发歧义）
    """
    return os.urandom(16).hex()


def _close_palm_tab_via_applescript(session_id: str) -> None:
    """通过 AppleScript 关闭 Chrome 中加载了本次会话页面的 tab。

    使用场景：
      - 主线程收到 /result 后立即调用，主动关掉 apple launcher 模式下的 tab
        （否则进程一旦退出，前端 fetch('/shutdown') 就联系不上后端，tab 会卡在
        "正在退出…"无人收尾）。
      - /shutdown 处理器里也会调一次（用户主动点"退出"按钮的路径）。
      - 双重调用安全：第二次执行时 tab 已经不存在，scanned/closed 都为 0。

    matching：URL contains "session=<session_id>"——精确匹配本次启动的 tab，
    既兼容端口降级，也避免误关用户其它 127.0.0.1 tab。
    """
    if platform.system() != "Darwin" or not session_id:
        return
    try:
        close_script = f"""
on run
    set closedCount to 0
    set scannedCount to 0
    tell application "Google Chrome"
        set winList to every window
        repeat with w in winList
            set tabList to every tab of w
            set tabIdx to (count of tabList)
            repeat while tabIdx > 0
                try
                    set tabUrl to URL of tab tabIdx of w
                    set scannedCount to scannedCount + 1
                    if tabUrl contains "session={session_id}" then
                        close tab tabIdx of w
                        set closedCount to closedCount + 1
                    end if
                end try
                set tabIdx to tabIdx - 1
            end repeat
        end repeat
    end tell
    return "scanned=" & scannedCount & " closed=" & closedCount
end run
"""
        r = subprocess.run(
            ["osascript", "-e", close_script],
            capture_output=True, text=True,
            timeout=5, check=False,
        )
        try:
            ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
            with open(LOG_FILE, "a", encoding="utf-8") as f:
                f.write(f"[{ts}] " + json.dumps({
                    "level": "INFO",
                    "msg": "CLOSE_PALM_TAB",
                    "session_id": session_id,
                    "rc": r.returncode,
                    "stdout": (r.stdout or "").strip(),
                    "stderr": (r.stderr or "").strip(),
                }, ensure_ascii=False) + "\n")
        except Exception:
            pass
    except Exception as ex:
        try:
            ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
            with open(LOG_FILE, "a", encoding="utf-8") as f:
                f.write(f"[{ts}] " + json.dumps({
                    "level": "ERROR",
                    "msg": "CLOSE_PALM_TAB_EXCEPTION",
                    "error": str(ex),
                }, ensure_ascii=False) + "\n")
        except Exception:
            pass


def start_http_server(serve_dir: str, port: int) -> None:
    """在后台守护线程中启动静态 HTTP 服务器，服务 skill 目录。"""
    class SilentHandler(SimpleHTTPRequestHandler):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, directory=serve_dir, **kwargs)

        def log_message(self, fmt, *args):
            pass  # 静默，不打印访问日志

        def do_POST(self):
            parsed = urlparse(self.path)

            if parsed.path == "/result":
                # POST /result  —— 页面写入注册/识别结果
                self.send_response(200)
                self.send_header("Content-Type", "application/json")
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                try:
                    length = int(self.headers.get("Content-Length", 0))
                    body = self.rfile.read(length).decode("utf-8") if length else "{}"
                    result = json.loads(body)
                    session_id = result.get("session_id", "")
                    if session_id:
                        # 把最近一次代理请求的 X-TraceId 合并到结果里，
                        # 这样 RESULT 行里就能直接带上 trace_id 给 Agent 展示。
                        with _TRACE_IDS_LOCK:
                            tid = _TRACE_IDS.get(session_id)
                        if tid and not result.get("trace_id"):
                            result["trace_id"] = tid
                        with _RESULTS_LOCK:
                            _RESULTS[session_id] = result
                        # 同时写日志
                        ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                        with open(LOG_FILE, "a", encoding="utf-8") as f:
                            f.write(f"[{ts}] " + json.dumps({
                                "level": "INFO",
                                "msg": "REGISTER_RESULT",
                                **result
                            }, ensure_ascii=False) + "\n")
                except Exception:
                    pass
                self.wfile.write(b'{"ok":true}')
                return

            if parsed.path == "/log":
                self.send_response(200)
                self.send_header("Content-Type", "application/json")
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                try:
                    length = int(self.headers.get("Content-Length", 0))
                    body = self.rfile.read(length).decode("utf-8") if length else "{}"
                    ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                    line = f"[{ts}] {body}\n"
                    with open(LOG_FILE, "a", encoding="utf-8") as f:
                        f.write(line)
                except Exception:
                    pass
                self.wfile.write(b'{"ok":true}')
                return

            if parsed.path == "/api/register":
                # 每次代理请求都生成全新 X-TraceId，并按 session 维度记下来，
                # 最终结果回传时合并进 RESULT JSON 的 trace_id 字段，方便 Agent 展示。
                trace_id = _gen_trace_id()
                # 从 query string 拿 session_id（页面在 fetch 路径中带上）；拿不到不影响主流程
                from urllib.parse import parse_qs as _parse_qs
                _qs = _parse_qs(parsed.query or "")
                _sid = (_qs.get("session") or [""])[0]
                if _sid:
                    with _TRACE_IDS_LOCK:
                        _TRACE_IDS[_sid] = trace_id

                try:
                    length = int(self.headers.get("Content-Length", 0))
                    req_body = self.rfile.read(length) if length else b"{}"

                    # 写请求日志
                    ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                    try:
                        req_json = json.loads(req_body)
                        # Data 截断避免日志过大
                        if "RgbImage" in req_json and "Data" in req_json["RgbImage"]:
                            data_len = len(req_json["RgbImage"]["Data"])
                            req_json["RgbImage"]["Data"] = f"[base64 {data_len} chars]"
                    except Exception:
                        req_json = "<parse error>"
                    with open(LOG_FILE, "a", encoding="utf-8") as f:
                        f.write(f"[{ts}] " + json.dumps({
                            "level": "INFO",
                            "msg": ">>> RegisterRgbPalm 请求（服务端代理）",
                            "url": "https://open.intl.palm.tencent.com/palm/openai/register_rgb_palm",
                            "Authorization": "Bearer ***（已配置）" if _PALM_TOKEN else "（未配置）",
                            "userid": _PALM_USERID if _PALM_USERID else "（未配置）",
                            "X-TraceId": trace_id,
                            "body": req_json
                        }, ensure_ascii=False) + "\n")

                    # 转发请求
                    proxy_req = urllib.request.Request(
                        "https://open.intl.palm.tencent.com/palm/openai/register_rgb_palm",
                        data=req_body,
                        headers={
                            "Content-Type": "application/json",
                            "Authorization": f"Bearer {_PALM_TOKEN}",
                            "X-TraceId": trace_id,
                        },
                        method="POST"
                    )
                    with urllib.request.urlopen(proxy_req, timeout=30, context=_UNVERIFIED_SSL_CTX) as resp:
                        resp_status = resp.status
                        resp_body = resp.read()

                except urllib.error.HTTPError as e:
                    resp_status = e.code
                    resp_body = e.read()
                except Exception as ex:
                    resp_status = 500
                    resp_body = json.dumps({"error": str(ex)}).encode()

                # 写响应日志
                ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                with open(LOG_FILE, "a", encoding="utf-8") as f:
                    try:
                        resp_json = json.loads(resp_body)
                    except Exception:
                        resp_json = resp_body.decode(errors="replace")
                    f.write(f"[{ts}] " + json.dumps({
                        "level": "INFO" if resp_status == 200 else "ERROR",
                        "msg": "<<< RegisterRgbPalm 响应（服务端代理）",
                        "status": resp_status,
                        "X-TraceId": trace_id,
                        "body": resp_json
                    }, ensure_ascii=False) + "\n")

                self.send_response(resp_status)
                self.send_header("Content-Type", "application/json")
                self.send_header("X-TraceId", trace_id)
                self.send_header("Access-Control-Expose-Headers", "X-TraceId")
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                self.wfile.write(resp_body)
                return

            if parsed.path == "/api/compare":
                # 每次代理请求都生成全新 X-TraceId，并按 session 维度记下来，
                # 最终结果回传时合并进 RESULT JSON 的 trace_id 字段，方便 Agent 展示。
                trace_id = _gen_trace_id()
                from urllib.parse import parse_qs as _parse_qs
                _qs = _parse_qs(parsed.query or "")
                _sid = (_qs.get("session") or [""])[0]
                if _sid:
                    with _TRACE_IDS_LOCK:
                        _TRACE_IDS[_sid] = trace_id

                try:
                    length = int(self.headers.get("Content-Length", 0))
                    req_body = self.rfile.read(length) if length else b"{}"

                    # 写请求日志
                    ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                    try:
                        req_json = json.loads(req_body)
                        if "RgbImage" in req_json and "Data" in req_json["RgbImage"]:
                            data_len = len(req_json["RgbImage"]["Data"])
                            req_json["RgbImage"]["Data"] = f"[base64 {data_len} chars]"
                    except Exception:
                        req_json = "<parse error>"
                    with open(LOG_FILE, "a", encoding="utf-8") as f:
                        f.write(f"[{ts}] " + json.dumps({
                            "level": "INFO",
                            "msg": ">>> CompareRgbPalm 请求（服务端代理）",
                            "url": "https://open.intl.palm.tencent.com/palm/openai/compare_rgb_palm",
                            "Authorization": "Bearer ***（已配置）" if _PALM_TOKEN else "（未配置）",
                            "userid": _PALM_USERID if _PALM_USERID else "（未配置）",
                            "X-TraceId": trace_id,
                            "body": req_json
                        }, ensure_ascii=False) + "\n")

                    # 转发请求
                    proxy_req = urllib.request.Request(
                        "https://open.intl.palm.tencent.com/palm/openai/compare_rgb_palm",
                        data=req_body,
                        headers={
                            "Content-Type": "application/json",
                            "Authorization": f"Bearer {_PALM_TOKEN}",
                            "X-TraceId": trace_id,
                        },
                        method="POST"
                    )
                    with urllib.request.urlopen(proxy_req, timeout=30, context=_UNVERIFIED_SSL_CTX) as resp:
                        resp_status = resp.status
                        resp_body = resp.read()

                except urllib.error.HTTPError as e:
                    resp_status = e.code
                    resp_body = e.read()
                except Exception as ex:
                    resp_status = 500
                    resp_body = json.dumps({"error": str(ex)}).encode()

                # 写响应日志
                ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
                with open(LOG_FILE, "a", encoding="utf-8") as f:
                    try:
                        resp_json = json.loads(resp_body)
                    except Exception:
                        resp_json = resp_body.decode(errors="replace")
                    f.write(f"[{ts}] " + json.dumps({
                        "level": "INFO" if resp_status == 200 else "ERROR",
                        "msg": "<<< CompareRgbPalm 响应（服务端代理）",
                        "status": resp_status,
                        "X-TraceId": trace_id,
                        "body": resp_json
                    }, ensure_ascii=False) + "\n")

                self.send_response(resp_status)
                self.send_header("Content-Type", "application/json")
                self.send_header("X-TraceId", trace_id)
                self.send_header("Access-Control-Expose-Headers", "X-TraceId")
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                self.wfile.write(resp_body)
                return

            self.send_response(404)
            self.end_headers()

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

        def do_GET(self):
            parsed = urlparse(self.path)
            if parsed.path == "/open-camera-settings":
                # 跨平台一键跳转到「摄像头权限设置」页面
                # macOS：x-apple.systempreferences 深链直达隐私与安全性 → 摄像头
                # Windows：ms-settings:privacy-webcam 深链
                # Linux：缺乏统一标准，仅在响应中说明
                self.send_response(200)
                self.send_header("Content-Type", "application/json")
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                try:
                    sysname = platform.system()
                    if sysname == "Darwin":
                        subprocess.Popen(
                            ["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera"],
                            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                        )
                        self.wfile.write(b'{"ok":true,"opened":"macos-privacy-camera"}')
                    elif sysname == "Windows":
                        subprocess.Popen(
                            ["cmd", "/c", "start", "ms-settings:privacy-webcam"],
                            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                            shell=False,
                        )
                        self.wfile.write(b'{"ok":true,"opened":"windows-privacy-webcam"}')
                    else:
                        self.wfile.write(b'{"ok":false,"reason":"linux not supported, please open system settings manually"}')
                except Exception as ex:
                    self.wfile.write(json.dumps({"ok": False, "error": str(ex)}).encode())
                return
            if parsed.path == "/shutdown":
                # 允许跨域（页面是同源 127.0.0.1，但保险起见加上）
                self.send_response(200)
                self.send_header("Content-Type", "text/plain")
                self.send_header("Access-Control-Allow-Origin", "*")
                self.end_headers()
                self.wfile.write(b"bye")
                # 延迟 200ms 后彻底退出，让响应先发出去
                def _do_exit():
                    # macOS 专属收尾：
                    # 1) apple launcher 模式下窗口由 AppleScript 创建（非 window.open 打开），
                    #    页面侧 window.close() 会被浏览器静默拒绝 → 必须由后端主动关 tab，
                    #    否则用户看到的就是"正在退出…"菊花一直转。这里通过本服务监听端口
                    #    （127.0.0.1:<port>）匹配 URL，关闭对应 tab；spawn 模式下也无害（找不到就跳过）。
                    # 2) 顺带从 Dock「最近使用应用」列表里移除 Chrome，避免图标残留。
                    if platform.system() == "Darwin":
                        # 1) 关闭加载了本服务页面的 Chrome tab（与主流程拿到 result 后的关 tab 同一函数）
                        _close_palm_tab_via_applescript(_CURRENT_SESSION_ID)
                        # 2) 从 Dock「最近使用应用」列表里移除 Chrome
                        try:
                            subprocess.run(
                                ["osascript", "-e", """
tell application "System Events"
    tell process "Dock"
        set dockItems to every UI element of list 2
        repeat with dockItem in dockItems
            if name of dockItem contains "Google Chrome" then
                perform action "AXShowMenu" of dockItem
                delay 0.1
                click menu item "Remove from Dock" of menu 1 of dockItem
                exit repeat
            end if
        end repeat
    end tell
end tell
"""],
                                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                timeout=3, check=False,
                            )
                        except Exception:
                            pass
                    os._exit(0)
                threading.Timer(0.2, _do_exit).start()
                return
            super().do_GET()

        def end_headers(self):
            # COOP/COEP 只对静态文件加（wasm 多线程需要），API 接口不加，否则浏览器会拦截响应
            parsed = urlparse(self.path)
            if parsed.path not in ("/result", "/log", "/api/register", "/api/compare", "/shutdown", "/open-camera-settings"):
                self.send_header("Cross-Origin-Opener-Policy", "same-origin")
                self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
            super().end_headers()

    # allow_reuse_address=True：与 _try_bind 保持一致。必须在实例化前设置类属性，
    # 因为 HTTPServer.__init__ 会在构造函数里调用 server_bind() 读取该标志。
    # 注：Python 标准库 HTTPServer 默认 allow_reuse_address=False（与 BaseHTTPServer 历史差异），
    # 所以必须在这里显式打开，避免上次 server 退出后 TIME_WAIT 占住导致 51823 bind 失败 → 端口飘。
    HTTPServer.allow_reuse_address = True
    server = HTTPServer(("127.0.0.1", port), SilentHandler)
    t = threading.Thread(target=server.serve_forever, daemon=True)
    t.start()


def is_server_alive(port: int) -> bool:
    """检查本地 HTTP 服务器是否仍在运行。"""
    try:
        with socket.create_connection(("127.0.0.1", port), timeout=1):
            return True
    except OSError:
        return False


def get_screen_size() -> tuple:
    """获取主屏幕分辨率，返回 (width, height)。"""
    sysname = platform.system()
    try:
        if sysname == "Darwin":
            from AppKit import NSScreen
            frame = NSScreen.mainScreen().frame()
            return int(frame.size.width), int(frame.size.height)
        elif sysname == "Windows":
            import ctypes
            user32 = ctypes.windll.user32
            return user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)
        else:  # Linux
            out = subprocess.run(
                ["xdpyinfo"],
                capture_output=True, text=True, check=False,
            )
            import re
            m = re.search(r"dimensions:\s+(\d+)x(\d+)", out.stdout)
            if m:
                return int(m.group(1)), int(m.group(2))
    except Exception:
        pass
    return 1920, 1080  # 默认回退值


def _spawn_chrome_window(args: list, w: int, h: int, x: int, y: int, chrome: str) -> object:
    """按平台选择最合适的方式启动 Chrome 独立小窗，返回 chrome_proc（Windows 哨兵 None）。

    传入的 args 已经包含 --user-data-dir / --app=URL / --window-size / --window-position 等参数。
    """
    sysname = platform.system()
    if sysname == "Windows":
        # Windows：用 ShellExecuteW 启动 Chrome，彻底脱离调用方的进程树。
        # 直接 Popen 时子进程会继承调用方权限上下文，在受限运行环境中可能无法访问摄像头。
        # ShellExecuteW 让 Chrome 由 Windows Shell（explorer.exe）接管，权限上下文独立。
        import ctypes

        def _quote_arg(a: str) -> str:
            if ' ' in a or '"' in a:
                return '"' + a.replace('"', '\\"') + '"'
            return a
        params = " ".join(_quote_arg(a) for a in args[1:])
        chrome_quoted = f'"{chrome}"' if ' ' in chrome else chrome
        ctypes.windll.shell32.ShellExecuteW(
            None, "open", chrome_quoted, params, None, 1,
        )
        return None  # ShellExecuteW 异步无句柄
    else:
        # macOS / Linux：直接 Popen + start_new_session，能可靠弹出独立 --app 小窗。
        return subprocess.Popen(
            args,
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
            start_new_session=True, close_fds=True,
        )


def _detect_user_chrome_profile() -> str:
    """读取用户主 Chrome 的 Local State，返回上次使用的 profile 目录名（如 'Default' / 'Profile 1'）。

    用途：apple 模式开窗时通过 --profile-directory 强制锁定到该 profile，避免
    多 profile 用户每次开到不同 profile 导致站点摄像头权限不复用。

    三级回退（按可靠性从高到低）：
      1) profile.last_used           —— 多 profile 用户的标准字段
      2) profile.last_active_profiles[0] —— 单/多 profile 用户都有
      3) profile.info_cache 的第一个 key —— 极简兜底，至少能拿到一个真实存在的 profile

    全部读不到时返回空串，调用方退化为不指定 profile（沿用 Chrome 当前激活 profile）。
    """
    if platform.system() != "Darwin":
        return ""
    home = _real_home()
    local_state = os.path.join(home, "Library", "Application Support", "Google", "Chrome", "Local State")
    if not os.path.exists(local_state):
        return ""
    try:
        with open(local_state, "r", encoding="utf-8") as f:
            data = json.load(f)
        prof = data.get("profile") or {}

        # 1) last_used
        v = prof.get("last_used")
        if isinstance(v, str) and v:
            return v

        # 2) last_active_profiles[0]
        lap = prof.get("last_active_profiles")
        if isinstance(lap, list) and lap and isinstance(lap[0], str) and lap[0]:
            return lap[0]

        # 3) info_cache 的第一个 key（按 profiles_order 的顺序更稳，没顺序就用插入顺序）
        order = prof.get("profiles_order")
        info = prof.get("info_cache") or {}
        if isinstance(order, list):
            for name in order:
                if isinstance(name, str) and name in info:
                    return name
        for name in info.keys():
            if isinstance(name, str) and name:
                return name
    except Exception as e:
        print(f"[open_chrome] WARN: read Chrome Local State failed: {e}", file=sys.stderr)
    return ""


def _apple_resize_palm_window(session_id: str, w: int, h: int, x: int, y: int) -> bool:
    """open -na 成功开窗后，把本次会话窗口 resize + 定位到 (x, y, w, h) 小窗。

    `open -na ... --args <url>` 不接受任何窗口尺寸参数（Chrome 主进程会忽略），
    新窗口尺寸完全由 Chrome 自己记忆的"上一次窗口大小"决定，通常是大窗 ——
    与 palm-kyc-community 想要的 500x600 浮动小窗体验严重不符 ——

    这里通过 AppleScript 找到 URL 中包含 `session=<session_id>` 的窗口，
    把它 bounds 设为 (x, y, x+w, y+h)。session_id 唯一，匹配是精确的，
    绝不会误改用户其它 127.0.0.1 / 同站点窗口。

    Chrome 加载 URL 是异步的，open -na 返回时新 tab 的 URL 字段可能还是 about:blank，
    所以这里用 AppleScript 自己做最多 ~3s 的轮询（每 0.1s 一次），命中即设 bounds。
    超时找不到也不报错（不影响主流程，最多就是窗口大一点）。
    """
    if platform.system() != "Darwin" or not session_id:
        return False
    # AppleScript bounds 是 {left, top, right, bottom}
    left, top, right, bottom = x, y, x + w, y + h
    script = f'''
set targetBounds to {{{left}, {top}, {right}, {bottom}}}
set deadline to (current date) + 3
tell application "Google Chrome"
    repeat
        repeat with win in windows
            try
                set tabUrl to URL of active tab of win
                if tabUrl contains "session={session_id}" then
                    set bounds of win to targetBounds
                    return "ok"
                end if
            end try
        end repeat
        if (current date) > deadline then
            return "timeout"
        end if
        delay 0.1
    end repeat
end tell
'''
    try:
        r = subprocess.run(
            ["osascript", "-e", script],
            capture_output=True, text=True, timeout=6,
        )
        out = (r.stdout or "").strip()
        if r.returncode == 0 and out == "ok":
            return True
        if out == "timeout":
            print(
                f"[open_chrome] INFO: cannot locate palm window within 3s, keeping default size",
                file=sys.stderr,
            )
        else:
            print(
                f"[open_chrome] WARN: resize palm window failed: {r.stderr.strip() or out or '(no output)'}",
                file=sys.stderr,
            )
    except Exception as e:
        print(f"[open_chrome] WARN: resize palm window invocation failed: {e}", file=sys.stderr)
    return False


def _is_chrome_running() -> bool:
    """检测用户是否已经在运行 Google Chrome（macOS 专用）。

    用 AppleScript 询问 System Events，比 pgrep 更可靠（pgrep 会把 helper 子进程也算进来）。
    Chrome 未运行时，AppleScript 的 `make new window` 会自己拉起 Chrome 但首次启动较慢，
    且容易被 Chrome 的"还原会话"对话框打断；因此这里要先判断，没运行时优先 `open -na`。
    """
    if platform.system() != "Darwin":
        return False
    try:
        r = subprocess.run(
            [
                "osascript", "-e",
                'tell application "System Events" to (name of processes) contains "Google Chrome"',
            ],
            capture_output=True, text=True, timeout=5,
        )
        return r.returncode == 0 and (r.stdout or "").strip().lower() == "true"
    except Exception:
        return False


def _apple_launch(page_url: str, w: int, h: int, x: int, y: int, session_id: str = "") -> bool:
    """AppleScript 路径：让用户已运行的 Chrome 主进程新开一个**独立小窗口**加载 URL。

    用于 macOS 受限 TCC 上下文（Electron 客户端 / qclaw 沙箱等）：
    用户已经在跑的 Chrome 主进程 PPID=launchd，TCC 直接归因到 Chrome 自己（已被用户授权），
    彻底绕开"父进程 TCC 归因受限"的问题。

    主策略（Chrome 已在运行）：
      AppleScript `make new window` 开一个**全新独立窗口**，立即 set bounds 应用 (x,y,w,h)，
      最后 set URL of active tab。这条路径有 3 个优点：
        1) 一定是独立新窗口（不会被合并到老 tab 组里），用户视觉上是清晰的小浮窗；
        2) set bounds 在 URL 加载之前就执行了，不存在"窗口先开成大窗、后来才缩小"的视觉抖动；
        3) 不依赖侦测 Chrome 用户的 last_used profile，更鲁棒。

    兜底策略（Chrome 没运行）：
      `open -na "Google Chrome" --args --profile-directory=<last_used> <url>` 把 Chrome 拉起来；
      然后调 `_apple_resize_palm_window` 用 session_id 找到对应窗口 resize 成小窗。

    返回是否成功开窗（False 时调用方应回退到 manual 路径打印 URL）。
    """
    if platform.system() != "Darwin":
        return False

    chrome_running = _is_chrome_running()
    print(
        f"[open_chrome] apple-launch: chrome_running={chrome_running}, "
        f"target_window={w}x{h} @ ({x},{y}), session={session_id[:16] if session_id else ''}",
        file=sys.stderr,
    )

    # 主路径：Chrome 已运行 → AppleScript make new window + 立即 set bounds
    if chrome_running:
        # AppleScript bounds 是 {left, top, right, bottom}
        bounds = f"{{{x}, {y}, {x + w}, {y + h}}}"
        # 用单独 escape 的 URL 防止意外字符破坏 AppleScript 字符串
        url_escaped = page_url.replace("\\", "\\\\").replace('"', '\\"')
        applescript = f'''
tell application "Google Chrome"
    activate
    set newWin to make new window
    set bounds of newWin to {bounds}
    set URL of active tab of newWin to "{url_escaped}"
    set winId to id of newWin
    set winBounds to bounds of newWin
    return "ok|" & winId & "|" & (item 1 of winBounds) & "," & (item 2 of winBounds) & "," & (item 3 of winBounds) & "," & (item 4 of winBounds)
end tell
'''
        try:
            r = subprocess.run(
                ["osascript", "-e", applescript],
                capture_output=True, text=True, timeout=10,
            )
            stdout = (r.stdout or "").strip()
            stderr = (r.stderr or "").strip()
            if r.returncode == 0 and stdout.startswith("ok|"):
                # ok|<winId>|<l>,<t>,<r>,<b>
                parts = stdout.split("|", 2)
                win_id = parts[1] if len(parts) > 1 else "?"
                applied_bounds = parts[2] if len(parts) > 2 else "?"
                print(
                    f"[open_chrome] apple-launch OK: opened new window id={win_id}, "
                    f"applied bounds=({applied_bounds}), expected ({x},{y},{x+w},{y+h})"
                )
                print(
                    f"[open_chrome] window size {w}x{h} applied at creation time "
                    f"(TCC attributes to Chrome itself)"
                )
                return True
            print(
                f"[open_chrome] WARN: AppleScript make-new-window failed "
                f"(rc={r.returncode}, stdout={stdout!r}, stderr={stderr!r}), "
                f"falling back to open -na",
                file=sys.stderr,
            )
        except Exception as e:
            print(
                f"[open_chrome] WARN: osascript invocation failed: {e}, falling back to open -na",
                file=sys.stderr,
            )

    # 兜底：Chrome 没运行 / AppleScript 失败 → 用 open -na 拉起 Chrome 再 resize
    profile = _detect_user_chrome_profile()
    print(
        f"[open_chrome] apple-launch fallback: open -na, profile={profile or '(default)'}",
        file=sys.stderr,
    )
    cmd = ["open", "-na", "Google Chrome", "--args"]
    if profile:
        cmd.append(f"--profile-directory={profile}")
    cmd.append(page_url)
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
        stderr = (r.stderr or "").strip()
        if r.returncode == 0:
            print(
                f"[open_chrome] open -na launched (rc=0), now resizing window via AppleScript...",
                file=sys.stderr,
            )
            resized = _apple_resize_palm_window(session_id, w, h, x, y)
            if resized:
                print(
                    f"[open_chrome] apple-launch fallback OK: window resized to {w}x{h} "
                    f"(profile='{profile or 'default'}')"
                )
            else:
                print(
                    f"[open_chrome] WARN: open -na succeeded but resize-to-small-window failed; "
                    f"window may appear larger than expected",
                    file=sys.stderr,
                )
            return True
        print(
            f"[open_chrome] ERROR: open -na failed (rc={r.returncode}), stderr={stderr!r}",
            file=sys.stderr,
        )
    except Exception as e:
        print(f"[open_chrome] ERROR: open -na invocation failed: {e}", file=sys.stderr)
    return False


def _apple_palm_tab_alive(session_id: str) -> bool:
    """探测 apple launcher 模式下，session 对应的 Chrome tab 是否仍然存活。

    用途：
      apple 模式下窗口由用户已运行的 Chrome 主进程持有，本脚本无 chrome_proc 句柄，
      无法通过 poll() 感知用户关窗/关 tab/退出 Chrome。结果就是用户一旦关掉浏览器，
      主进程会一直死等到 5 分钟超时——体验非常差。

      这里通过 AppleScript 遍历所有 Chrome 窗口的所有 tab，匹配 URL 中的
      `session=<session_id>`，命中即认为存活。匹配是精确的（session_id 是本次会话
      唯一生成），不会受用户其它 127.0.0.1 / 同站 tab 干扰。

    返回值语义：
      - True  : 窗口/tab 仍存在（包括 Chrome 整体仍在运行且能找到匹配 tab）
      - False : tab 已被关闭，或 Chrome 已退出，或 osascript 调用失败
                （AppleScript 调用失败时谨慎起见返回 True，避免假阳性误退）

    实现细节：
      - 先用 `application "System Events"` 判断 Chrome 进程是否还在；不在直接返回 False，
        免得跑 `tell application "Google Chrome"` 触发 Chrome 重新拉起。
      - osascript 单次调用约 30~80ms，调用方应控制频率（建议 ≥1.5s 一次）。
    """
    if platform.system() != "Darwin" or not session_id:
        return True  # 无法判定 → 视为存活，避免误退
    script = f'''
on run
    tell application "System Events"
        if not ((name of processes) contains "Google Chrome") then
            return "chrome_dead"
        end if
    end tell
    tell application "Google Chrome"
        repeat with w in every window
            try
                repeat with t in every tab of w
                    try
                        if (URL of t) contains "session={session_id}" then
                            return "alive"
                        end if
                    end try
                end repeat
            end try
        end repeat
    end tell
    return "tab_gone"
end run
'''
    try:
        r = subprocess.run(
            ["osascript", "-e", script],
            capture_output=True, text=True, timeout=4, check=False,
        )
        out = (r.stdout or "").strip()
        if r.returncode != 0:
            # AppleScript 调用失败（极少见）→ 谨慎起见视为存活，避免假阳性
            return True
        return out == "alive"
    except Exception:
        return True  # 调用异常（超时/系统压力大）→ 视为存活，避免误退


def _print_manual_instructions(page_url: str, page: str) -> None:
    """打印 URL 让用户手动用本机 Chrome 打开（最后兜底，跨平台通用）。"""
    print()
    print("=" * 70)
    print(f"📋 请用本机 Chrome 手动打开以下 URL（{'录掌注册' if page == 'register' else '刷掌验证'}）")
    print("=" * 70)
    print(page_url)
    print("=" * 70)
    if platform.system() == "Darwin":
        print(f"  快捷命令：open -a 'Google Chrome' '{page_url}'")
    print()
    sys.stdout.flush()


def launch(page: str, w: int, h: int, x: int, y: int, session_id: str = "", launcher: str = "auto") -> int:
    """启动 palm-kyc-community 浏览器小窗并阻塞等待结果。

    launcher 启动策略：
      - 'auto'   : 自动选择（macOS 受限 TCC 上下文走 apple，否则 spawn；其他平台一律 spawn）
      - 'spawn'  : 直接 Popen 一个独立 user-data-dir 的 Chrome（默认 --app 纯净小窗）
      - 'apple'  : macOS AppleScript，借用户已运行的 Chrome 主进程开新窗
      - 'manual' : 仅启 HTTP server 并打印 URL，让用户手动用本机 Chrome 打开
    """
    if page not in ("register", "verify"):
        print(f"[open_chrome] invalid page: {page} (must be 'register' or 'verify')",
              file=sys.stderr)
        return 2
    if launcher not in ("auto", "spawn", "apple", "manual"):
        print(f"[open_chrome] invalid launcher: {launcher} (must be auto/spawn/apple/manual)",
              file=sys.stderr)
        return 2

    page_path = os.path.join(SKILL_DIR, "palm.html")
    if not os.path.exists(page_path):
        print(f"[open_chrome] ERROR: page not found: {page_path}", file=sys.stderr)
        return 1

    # 自动判定：macOS 受限 TCC 上下文 → apple；其它一律 spawn
    if launcher == "auto":
        is_restricted, reason = detect_restricted_tcc()
        if is_restricted:
            launcher = "apple"
            print(f"[open_chrome] restricted TCC context detected ({reason}) → using AppleScript launcher", file=sys.stderr)
        else:
            launcher = "spawn"

    # apple 启动器仅 macOS 可用；非 macOS 上自动降级 spawn
    if launcher == "apple" and platform.system() != "Darwin":
        print("[open_chrome] WARN: 'apple' launcher only works on macOS, falling back to 'spawn'", file=sys.stderr)
        launcher = "spawn"

    # spawn 启动器需要本机 Chrome；apple/manual 借用户已有 Chrome，无需检测
    chrome = ""
    user_dir = ""
    if launcher == "spawn":
        chrome = detect_chrome_bin()
        if not chrome:
            print("[open_chrome] ERROR: Chrome not found. Set CHROME_BIN or install Google Chrome.",
                  file=sys.stderr)
            print("[open_chrome] Hint: on macOS you can also try --launcher=apple to use your existing Chrome.",
                  file=sys.stderr)
            return 1
        user_dir = default_user_dir()
        os.makedirs(user_dir, exist_ok=True)
        kill_prev(user_dir)
        patch_prefs(user_dir)

    # 启动本地 HTTP 服务器（解决 file:// 下 wasm 无法加载的问题）
    # 使用固定端口 51823 + 占用降级随机端口，避免 Chrome 站点摄像头授权按 origin 反复重申
    port = pick_port()
    start_http_server(SKILL_DIR, port)
    # 等服务器就绪
    time.sleep(0.3)

    # 读取凭证存入全局供代理接口使用（不注入页面，代理层统一处理）
    global _PALM_TOKEN, _PALM_USERID
    _PALM_TOKEN = read_cred("token")
    _PALM_USERID = read_cred("userid")

    # 凭证完整性校验：任意一个缺失则拒绝启动
    missing = []
    if not _PALM_TOKEN:
        missing.append("token")
    if not _PALM_USERID:
        missing.append("userid")
    if missing:
        print(f"[open_chrome] ERROR: missing credentials, cannot start: {', '.join(missing)}", file=sys.stderr)
        print(f"[open_chrome] Please run the following command(s) to configure:", file=sys.stderr)
        for key in missing:
            print(f"[open_chrome]   python3 {CREDS_PY} set {key}", file=sys.stderr)
        return 1

    # 用 http://127.0.0.1:PORT 替代 file://
    # 使用 AI 传入的 session_id，若未传则自动生成（向后兼容）
    if not session_id:
        session_id = f"palm_{page}_{random.randint(100000, 999999)}"
    # 暴露到模块全局，供 /shutdown 的 AppleScript 按 session 精确关闭本次 tab
    global _CURRENT_SESSION_ID
    _CURRENT_SESSION_ID = session_id
    page_url = f"http://127.0.0.1:{port}/palm.html?mode={page}&session={session_id}&userid={_PALM_USERID}"

    # 自动居中：若 x/y 为默认值（200/150），则根据屏幕尺寸计算居中坐标
    if x == 200 and y == 150:
        sw, sh = get_screen_size()
        x = max(0, (sw - w) // 2)
        y = max(0, (sh - h) // 2)

    # 按 launcher 类型分发 ──────────────────────────────────────────────
    chrome_proc = None
    if launcher == "spawn":
        args = [
            chrome,
            f"--user-data-dir={user_dir}",
            "--no-first-run",
            "--no-default-browser-check",
            "--disable-sync",
            "--disable-features=ChromeWhatsNewUI",
            f"--app={page_url}",
            f"--window-size={w},{h}",
            f"--window-position={x},{y}",
        ]
        chrome_proc = _spawn_chrome_window(args, w, h, x, y, chrome)
    elif launcher == "apple":
        opened = _apple_launch(page_url, w, h, x, y, session_id)
        if not opened:
            # AppleScript 失败 → 退回 manual：打印 URL 让用户自己开
            _print_manual_instructions(page_url, page)
        chrome_proc = None
    else:  # manual
        _print_manual_instructions(page_url, page)
        chrome_proc = None

    print(f"[open_chrome] launched {page} (launcher={launcher}): {w}x{h} @ ({x},{y})")
    print(f"[open_chrome] session_id: {session_id}")

    # 打印用户可见的引导语（AI 无需手动输出，由脚本统一负责）
    if page == "verify":
        print(f"\nChrome verification window is open. Session: {session_id}. Waiting up to 5 minutes.\n")
        print("Please look at the Chrome pop-up window:")
        print("1. Place your palm in front of the camera (about 20-30 cm away).")
        print("2. Once your palm is detected, click 'Start Verification'.")
        print("3. The script will return the result automatically — no need to report it manually.\n")
    elif page == "register":
        print(f"\nChrome enrollment window is open. Session: {session_id}. Waiting up to 5 minutes.\n")
        print("Please look at the Chrome pop-up window:")
        print("1. Place your palm in front of the camera (about 20-30 cm away).")
        print("2. The system will auto-capture once a palm is detected (around 3-5 frames).")
        print("3. Click the 'Register' button to complete enrollment.")
        print("4. The script will return the result automatically — no need to report it manually.\n")
    sys.stdout.flush()

    # macOS spawn 路径：等待 Chrome 窗口渲染完成后激活置顶
    # （apple 路径在 AppleScript 里已经 activate 过了；manual 路径无窗口可激活）
    if platform.system() == "Darwin" and launcher == "spawn":
        def _bring_to_front():
            time.sleep(1.5)  # 等待 Chrome 窗口渲染完成
            try:
                subprocess.run(
                    ["osascript", "-e",
                     'tell application "Google Chrome" to activate'],
                    stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                    timeout=5, check=False,
                )
            except Exception:
                pass
        threading.Thread(target=_bring_to_front, daemon=True).start()
    print(f"[open_chrome] token: {'configured' if _PALM_TOKEN else 'MISSING'}")
    print(f"[open_chrome] userid: {'configured' if _PALM_USERID else 'MISSING'}")
    sys.stdout.flush()

    # 同步阻塞：等待用户在浏览器完成操作，直接从内存结果返回给 AI
    # 注意：注册失败时页面不会向 /result 写入结果（避免服务器提前退出导致重试时 fetch 必然失败）
    # 因此注册失败时主进程会继续等待，直到用户重试成功或超时退出
    POLL_INTERVAL = 1      # 每秒检查一次内存结果
    TIMEOUT_SECS  = 300    # 最多等待 5 分钟
    REMIND_EVERY  = 30     # 每 30 秒提醒一次
    print(f"[open_chrome] waiting for user to complete operation (timeout={TIMEOUT_SECS}s)...")
    sys.stdout.flush()

    elapsed = 0
    result = None
    user_closed = False  # 区分"用户主动关浏览器" vs "真超时"，影响最终 RESULT 的 status 字段
    # Chrome 关闭检测策略（按 launcher 区分，因为 chrome_proc 的可用性差异较大）：
    #   - spawn 模式：chrome_proc 是 Popen 句柄，直接用 poll() 实时检测，最廉价
    #   - apple 模式：chrome_proc 为 None，窗口由用户已运行的 Chrome 主进程托管，
    #                 用 AppleScript 周期性探测 session 对应的 tab 是否还在
    #   - manual 模式：脚本不知道用户用哪个浏览器开的，无法检测，只能等超时
    #   - Windows ShellExecuteW：chrome_proc 也是 None，HTTP 服务器由本进程托管始终存活，
    #                            无可靠廉价手段判断，跳过检测
    APPLE_TAB_PROBE_INTERVAL = 2  # apple 模式下每 2 秒探测一次 tab 存活
    APPLE_TAB_PROBE_GRACE    = 6  # 启动后 6 秒内不探测，避免 Chrome 还没加载完页面误判
    apple_probe_consecutive_misses = 0  # 连续探测不到 tab 的次数，达到阈值才确认关闭（去抖）
    APPLE_PROBE_MISS_THRESHOLD = 2     # 需连续 2 次探测都缺失，才认定用户已关闭浏览器
    try:
        while elapsed < TIMEOUT_SECS:
            time.sleep(POLL_INTERVAL)
            elapsed += POLL_INTERVAL
            with _RESULTS_LOCK:
                result = _RESULTS.get(session_id)
            if result:
                break
            # 每 30 秒提醒
            if elapsed % REMIND_EVERY == 0:
                action = "enrollment" if page == "register" else "verification"
                print(f"[open_chrome] REMIND: please place your palm in front of the camera in the palm-{action} window to complete the operation. (waited {elapsed}s)")
                sys.stdout.flush()
            # 检测 Chrome 是否已关闭
            if elapsed > 5:
                if launcher == "apple":
                    # apple 模式：用 AppleScript 探测 session tab 是否还在
                    # 仅在过了启动宽限期、且按 PROBE_INTERVAL 节流时探测
                    if elapsed >= APPLE_TAB_PROBE_GRACE and (elapsed % APPLE_TAB_PROBE_INTERVAL == 0):
                        if _apple_palm_tab_alive(session_id):
                            apple_probe_consecutive_misses = 0
                        else:
                            apple_probe_consecutive_misses += 1
                            if apple_probe_consecutive_misses >= APPLE_PROBE_MISS_THRESHOLD:
                                user_closed = True
                                print(f"[open_chrome] Chrome window/tab closed by user, exiting early (waited {elapsed}s).")
                                sys.stdout.flush()
                                break
                elif chrome_proc is not None:
                    # spawn 模式：直接用 Popen 句柄的 poll() 检测
                    if chrome_proc.poll() is not None:
                        user_closed = True
                        print(f"[open_chrome] Chrome closed without result, exiting.")
                        sys.stdout.flush()
                        break
                # else: manual / Windows ShellExecuteW → 无可靠廉价检测手段，等超时
    except KeyboardInterrupt:
        pass

    if result:
        # 兜底：如果页面写回的结果没带 trace_id，但内存里有 _TRACE_IDS 记录，则补上
        if not result.get("trace_id"):
            with _TRACE_IDS_LOCK:
                tid = _TRACE_IDS.get(session_id)
            if tid:
                result["trace_id"] = tid
        # 主动收尾：在打印 RESULT/END 之前先把浏览器 tab 关掉。
        # 必须在这里关：进程在 print END 后会 sys.exit(0)，HTTP 服务器会随之死掉；
        # 前端 fetch('/shutdown') 是在 EXIT_DELAY=2500ms 后才发出的，那时服务器早已不在，
        # apple launcher 模式下 window.close() 又被浏览器拒绝 → tab 永远卡在"正在退出…"。
        _close_palm_tab_via_applescript(session_id)
        print(f"[open_chrome] RESULT: {json.dumps(result, ensure_ascii=False)}")
    else:
        # 超时 / 用户主动关闭分支：把最近一次代理请求的 traceId 也带上，方便用户报障时定位后端日志
        with _TRACE_IDS_LOCK:
            tid = _TRACE_IDS.get(session_id)
        timeout_payload = {
            "session_id": session_id,
            # user_closed=True 表示用户在浏览器里关掉了窗口/tab，不需要 AI 等待重试；
            # user_closed=False 表示真的等满了 5 分钟超时
            "status": "user_closed" if user_closed else "timeout",
            "found": False,
        }
        if tid:
            timeout_payload["trace_id"] = tid
        # 兜底也关一次 tab：用户可能已自行关闭（无害，scanned/closed 都为 0）
        _close_palm_tab_via_applescript(session_id)
        print(f"[open_chrome] RESULT: {json.dumps(timeout_payload, ensure_ascii=False)}")
    # 尾标：AI 看到 END 即表示结果已完整返回，禁止再读取 palm-debug.log
    print("[open_chrome] END")
    sys.stdout.flush()
    return 0


def main():
    if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"):
        print(__doc__)
        sys.exit(0)

    page = sys.argv[1]
    # 解析具名参数：--session=<id>, --launcher=auto|spawn|apple|manual
    raw_args = sys.argv[2:]
    session_id = ""
    launcher = "auto"
    rest = []
    for arg in raw_args:
        if arg.startswith("--session="):
            session_id = arg[len("--session="):].strip()
        elif arg.startswith("--launcher="):
            launcher = arg[len("--launcher="):].strip().lower()
        else:
            rest.append(arg)

    def _int(idx, default):
        try:
            return int(rest[idx]) if idx < len(rest) else default
        except ValueError:
            return default

    w = _int(0, 500)
    h = _int(1, 500)
    x = _int(2, 200)
    y = _int(3, 150)

    sys.exit(launch(page, w, h, x, y, session_id, launcher))


if __name__ == "__main__":
    main()
