#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Fuck Claude — Desktop Scanner
Claude Code 中国用户风险自检（桌面版）

浏览器读不到的东西，这个能读到：真实系统时区、系统区域设置、
ANTHROPIC_BASE_URL、代理环境变量、TUN 虚拟网卡、hosts 篡改、
Claude 客户端残留、系统中文字体、甚至你的 Windows 用户名是不是中文。

用法：
    python fuck_claude.py            # 检测并生成 HTML 报告（自动打开）
    python fuck_claude.py --cli      # 纯终端输出
    python fuck_claude.py --json     # 输出 JSON
    python fuck_claude.py --no-open  # 只生成报告不打开
    python fuck_claude.py --offline  # 跳过联网查询

零第三方依赖，纯标准库。
"""

import os
import sys
import json
import time
import platform
import subprocess
import socket
import re
import html
import argparse
import tempfile
import webbrowser
from datetime import datetime

IS_WIN = sys.platform.startswith("win")
IS_MAC = sys.platform == "darwin"

APP_NAME = "Fuck Claude"
APP_VER = "1.1"

# 已知中转/聚合域名关键词（用于粗略匹配，非完整名单）
RELAY_KEYWORDS = [
    "baidu", "aliyun", "alibaba", "tencent", "bytedance", "volcengine",
    "jd.com", "moonshot", "minimax", "zhipu", "baichuan", "01.ai", "lingyi",
    "sensenova", "deepseek", "qwen", "dashscope", "siliconflow",
    "api2d", "openai-proxy", "oneapi", "newapi", "closeai", "aiproxy",
    "chatapi", "gptapi", "claude-api", "anthropic-proxy",
]
LAB_KEYWORDS = ["openai", "anthropic", "claude", "gpt", "gemini", "llm", "ai-lab", "ailab"]


# ---------------------------------------------------------------- 基础工具
def run(cmd, timeout=6):
    """执行命令，返回 stdout（失败返回空串）。"""
    try:
        p = subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True,
                           timeout=timeout, text=True,
                           encoding="utf-8", errors="replace")
        return (p.stdout or "").strip()
    except Exception:
        return ""


def env(name):
    return os.environ.get(name, "")


def host_of(url):
    if not url:
        return ""
    m = re.sub(r'^[a-zA-Z]+://', '', url.strip())
    m = m.split("/")[0].split("?")[0]
    m = m.split("@")[-1]
    m = re.sub(r':\d+$', '', m)
    return m.lower()


# ---------------------------------------------------------------- 时区 / 区域
def get_tz():
    """返回 (时区名, 偏移小时)。尽量拿 OS 层的真值。"""
    name, offset = "", None
    try:
        offset = -time.timezone / 3600 if not time.daylight else -time.altzone / 3600
    except Exception:
        pass

    if IS_WIN:
        raw = run("tzutil /g")
        mapping = {
            "China Standard Time": "Asia/Shanghai",
            "Taipei Standard Time": "Asia/Taipei",
            "Tokyo Standard Time": "Asia/Tokyo",
            "Korea Standard Time": "Asia/Seoul",
            "Singapore Standard Time": "Asia/Singapore",
            "Pacific Standard Time": "America/Los_Angeles",
            "Eastern Standard Time": "America/New_York",
            "GMT Standard Time": "Europe/London",
            "W. Europe Standard Time": "Europe/Berlin",
        }
        name = mapping.get(raw, raw)
    elif IS_MAC:
        try:
            link = os.path.realpath("/etc/localtime")
            m = re.search(r'zoneinfo/(.+)$', link)
            if m:
                name = m.group(1)
        except Exception:
            pass
        if not name:
            raw = run("systemsetup -gettimezone")
            if ":" in raw:
                name = raw.split(":", 1)[1].strip()
    else:
        for p in ("/etc/timezone", "/etc/TIMEZONE"):
            try:
                with open(p, encoding="utf-8", errors="replace") as f:
                    name = f.read().strip()
                if name:
                    break
            except Exception:
                pass
        if not name:
            link = os.path.realpath("/etc/localtime")
            m = re.search(r'zoneinfo/(.+)$', link)
            if m:
                name = m.group(1)

    try:
        offset = -time.timezone / 3600
        # 用当前时间判断夏令时
        offset = -(time.altzone if time.daylight and time.localtime().tm_isdst else time.timezone) / 3600
    except Exception:
        pass
    return name, offset


def get_locale():
    """系统区域设置，返回 (locale, 语言列表)。"""
    loc, langs = "", []
    if IS_WIN:
        try:
            import ctypes
            buf = ctypes.create_unicode_buffer(85)
            ctypes.windll.kernel32.GetUserDefaultLocaleName(buf, 85)
            loc = buf.value
            lb = ctypes.create_unicode_buffer(85)
            ctypes.windll.kernel32.GetUserDefaultUILanguage
            # 语言名
            lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage()
            langid = lcid & 0xFFFF
            names = {0x0804: "zh-CN", 0x0404: "zh-TW", 0x0C04: "zh-HK",
                     0x0409: "en-US", 0x0809: "en-GB", 0x0411: "ja-JP", 0x0412: "ko-KR"}
            langs.append(names.get(langid, hex(langid)))
        except Exception:
            pass
    elif IS_MAC:
        loc = run("defaults read -g AppleLocale")
        raw = run("defaults read -g AppleLanguages")
        for m in re.findall(r'"([^"]+)"', raw):
            langs.append(m)
    else:
        loc = env("LANG") or env("LC_ALL")
    if not loc:
        loc = env("LANG") or env("LC_ALL") or "(未知)"
    return loc, langs


def fmt_date_sample():
    """用当前区域设置格式化一个日期，看是不是中文习惯。"""
    try:
        d = datetime(2026, 11, 6)
        if IS_WIN:
            import locale
            try:
                locale.setlocale(locale.LC_ALL, "")
            except Exception:
                pass
            return d.strftime("%x")
        return d.strftime("%x")
    except Exception:
        return ""


# ---------------------------------------------------------------- 字体
def count_fonts():
    """返回 (字体文件数, 中文字体文件数, 样本)。"""
    dirs = []
    if IS_WIN:
        dirs = [os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts"),
                os.path.join(os.environ.get("LOCALAPPDATA", ""), r"Microsoft\Windows\Fonts")]
    elif IS_MAC:
        dirs = ["/System/Library/Fonts", "/Library/Fonts",
                os.path.expanduser("~/Library/Fonts")]
    else:
        dirs = ["/usr/share/fonts", "/usr/local/share/fonts",
                os.path.expanduser("~/.fonts"), os.path.expanduser("~/.local/share/fonts")]

    total, cjk, samples = 0, 0, []
    cjk_hint = ["simsun", "simhei", "msyh", "msjh", "simkai", "simfang", "deng",
                "pingfang", "hiragino", "songti", "heiti", "stheiti", "kaiti",
                "notosanscjk", "notoserifcjk", "sourcehans", "wqy", "uming", "ukai",
                "droidsansfallback", "微软雅黑", "宋体", "黑体", "楷体"]
    for d in dirs:
        if not d or not os.path.isdir(d):
            continue
        try:
            for root, _, files in os.walk(d):
                for f in files:
                    if f.lower().endswith((".ttf", ".ttc", ".otf", ".otc")):
                        total += 1
                        low = f.lower()
                        if any(h in low for h in cjk_hint):
                            cjk += 1
                            if len(samples) < 6:
                                samples.append(f)
        except Exception:
            continue
    return total, cjk, samples


# ---------------------------------------------------------------- 环境变量 / 代理
def check_env_relay():
    base = env("ANTHROPIC_BASE_URL")
    key = env("ANTHROPIC_AUTH_TOKEN") or env("ANTHROPIC_API_KEY")
    h = host_of(base)
    known = [k for k in RELAY_KEYWORDS if k in h] if h else []
    lab = [k for k in LAB_KEYWORDS if k in h] if h else []
    return {
        "base": base,
        "host": h,
        "has_key": bool(key),
        "key_prefix": (key[:8] + "...") if key else "",
        "known": known,
        "lab": lab,
    }


def check_proxy_env():
    keys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy",
            "NO_PROXY", "no_proxy"]
    out = {k: env(k) for k in keys if env(k)}
    return out


def check_tun():
    """检测 TUN/TAP 虚拟网卡，判断代理是不是全局模式。"""
    found = []
    if IS_WIN:
        raw = run("ipconfig /all")
        for m in re.finditer(r'Description[^\n]*:\s*(.+)', raw):
            desc = m.group(1).strip()
            if re.search(r'tap|tun|wintun|wireguard|clash|v2ray|singbox|sing-box|openvpn|proxy|虚拟', desc, re.I):
                found.append(desc)
    elif IS_MAC:
        raw = run("ifconfig")
        for m in re.finditer(r'^(utun\d+|tun\d+|tap\d+)', raw, re.M):
            found.append(m.group(1))
        raw2 = run("networksetup -listallnetworkservices")
        for line in raw2.splitlines():
            if re.search(r'clash|surge|v2ray|wireguard|tailscale|proxy', line, re.I):
                found.append(line.strip())
    else:
        raw = run("ip link show")
        for m in re.finditer(r'^\d+:\s+(\S+):', raw, re.M):
            n = m.group(1)
            if re.match(r'(tun|tap|utun|wg|clash|singbox)', n):
                found.append(n)
    return found


def check_hosts():
    p = r"C:\Windows\System32\drivers\etc\hosts" if IS_WIN else "/etc/hosts"
    hits = []
    try:
        with open(p, encoding="utf-8", errors="replace") as f:
            for line in f:
                s = line.strip()
                if s.startswith("#") or not s:
                    continue
                if re.search(r'anthropic|claude', s, re.I):
                    hits.append(s)
    except Exception:
        pass
    return hits


def get_dns():
    out = []
    if IS_WIN:
        raw = run("ipconfig /all")
        out = re.findall(r'DNS Servers[^\n]*:\s*([\d\.:a-fA-F]+)', raw)
    else:
        try:
            with open("/etc/resolv.conf", encoding="utf-8", errors="replace") as f:
                out = re.findall(r'nameserver\s+(\S+)', f.read())
        except Exception:
            pass
    return out[:4]


# ---------------------------------------------------------------- Claude 痕迹
def check_claude_files():
    home = os.path.expanduser("~")
    res = {}

    claude_dir = os.path.join(home, ".claude")
    res["~/.claude 目录"] = os.path.isdir(claude_dir)

    settings = os.path.join(claude_dir, "settings.json")
    if os.path.isfile(settings):
        try:
            with open(settings, encoding="utf-8", errors="replace") as f:
                txt = f.read()
            res["settings.json"] = "%d 字节" % len(txt)
            m = re.search(r'"(ANTHROPIC_BASE_URL|baseURL|base_url)"\s*:\s*"([^"]+)"', txt)
            if m:
                res["settings 内的 base_url"] = m.group(2)
        except Exception:
            res["settings.json"] = "存在但读取失败"
    else:
        res["settings.json"] = False

    cj = os.path.join(home, ".claude.json")
    if os.path.isfile(cj):
        try:
            res["~/.claude.json"] = "%.1f MB" % (os.path.getsize(cj) / 1048576)
        except Exception:
            res["~/.claude.json"] = "存在"
    else:
        res["~/.claude.json"] = False

    if IS_WIN:
        appdir = os.path.join(os.environ.get("APPDATA", ""), "Claude")
    elif IS_MAC:
        appdir = os.path.join(home, "Library", "Application Support", "Claude")
    else:
        appdir = os.path.join(home, ".config", "Claude")
    res["Claude 桌面客户端"] = os.path.isdir(appdir)

    return res


def check_claude_process():
    if IS_WIN:
        raw = run('wmic process where "name=\'node.exe\'" get CommandLine /format:list')
        return "claude" in raw.lower()
    else:
        raw = run("ps -Ao command")
        return bool(re.search(r'claude', raw, re.I))


# ---------------------------------------------------------------- 只有桌面端能读的硬信号
def reg_read(path, name):
    """读 Windows 注册表值，失败返回 None。"""
    if not IS_WIN:
        return None
    try:
        import winreg
        root, sub = path.split("\\", 1)
        hive = {"HKLM": winreg.HKEY_LOCAL_MACHINE, "HKCU": winreg.HKEY_CURRENT_USER}[root]
        with winreg.OpenKey(hive, sub) as k:
            v, _ = winreg.QueryValueEx(k, name)
            return v
    except Exception:
        return None


def check_codepage():
    """非 Unicode 程序的语言（ACP）。936 = 简体中文。"""
    if IS_WIN:
        v = reg_read(r"HKLM\SYSTEM\CurrentControlSet\Control\Nls\CodePage", "ACP")
        return str(v) if v else "(读取失败)"
    if IS_MAC:
        return "(macOS 无此项)"
    return run("locale charmap") or "(未知)"


def check_install_lang():
    """系统安装时的语言。改区域设置也不会变。"""
    if IS_WIN:
        v = reg_read(r"HKLM\SYSTEM\CurrentControlSet\Control\Nls\Language", "InstallLanguage")
        m = {"0804": "zh-CN 简体中文", "0404": "zh-TW 繁体中文", "0C04": "zh-HK 繁体中文",
             "0409": "en-US", "0809": "en-GB", "0411": "ja-JP", "0412": "ko-KR"}
        return m.get(str(v), str(v)) if v else "(读取失败)"
    if IS_MAC:
        return run("defaults read -g AppleLocale") or "(未知)"
    return env("LANG") or "(未知)"


def check_ime():
    """是否装了中文输入法。"""
    if IS_WIN:
        hits = []
        try:
            import winreg
            with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Keyboard Layout\Preload") as k:
                i = 0
                while True:
                    try:
                        v, _ = winreg.EnumValue(k, i)
                        s = str(v).lower()
                        if "0804" in s or "e020" in s or "e001" in s:
                            hits.append(v)
                        i += 1
                    except OSError:
                        break
        except Exception:
            pass
        return hits
    if IS_MAC:
        raw = run("defaults read com.apple.HIToolbox AppleEnabledInputSources")
        return [x for x in ("Pinyin", "SCIM", "Simplified", "Sogou", "Baidu") if x.lower() in raw.lower()]
    return []


def check_cn_software():
    """扫已安装软件里的国产软件。这是台"中国机器"很直接的证据。"""
    names = []
    if IS_WIN:
        keys = [r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
                r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"]
        try:
            import winreg
            for kp in keys:
                try:
                    with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, kp) as root:
                        for i in range(winreg.QueryInfoKey(root)[0]):
                            try:
                                sub = winreg.EnumKey(root, i)
                                with winreg.OpenKey(root, kp + "\\" + sub) as k:
                                    dn, _ = winreg.QueryValueEx(k, "DisplayName")
                                    dn = str(dn)
                                    if re.search(r'微信|WeChat|QQ|钉钉|DingTalk|迅雷|Thunder|百度网盘|'
                                                 r'BaiduNetdisk|网易云|WPS|搜狗|Sogou|360|腾讯|Tencent|'
                                                 r'阿里|Alibaba|飞书|Lark|抖音|Douyin', dn, re.I):
                                        names.append(dn)
                            except Exception:
                                continue
                except Exception:
                    continue
        except Exception:
            pass
    elif IS_MAC:
        for d in ("/Applications", os.path.expanduser("~/Applications")):
            try:
                for f in os.listdir(d):
                    if re.search(r'微信|WeChat|QQ|钉钉|DingTalk|迅雷|WPS|搜狗|Sogou|飞书|Lark|抖音|'
                                 r'百度|Baidu|腾讯|Tencent|阿里', f, re.I):
                        names.append(f.replace(".app", ""))
            except Exception:
                continue
    return sorted(set(names))[:8]


def check_netif():
    """网卡数量与是否全走虚拟网卡。"""
    if IS_WIN:
        raw = run("ipconfig")
        n = len(re.findall(r'适配器|adapter', raw, re.I))
        return n
    if IS_MAC:
        raw = run("ifconfig -l")
        return len(raw.split())
    return len(run("ip -o link show").splitlines())


def check_keyboard():
    if IS_WIN:
        v = reg_read(r"HKCU\Keyboard Layout\Preload", "1")
        m = {"00000804": "简体中文", "00000404": "繁体中文", "00000409": "美式英语"}
        return m.get(str(v), str(v)) if v else "(未知)"
    if IS_MAC:
        return run("defaults read -g AppleKeyboardLayout") or "(未知)"
    return run("setxkbmap -query") or "(未知)"


def check_browser_lang():
    """直接读 Chrome/Edge 的 Preferences，拿真实的 accept_languages。
    这是网页层面永远拿不到的：页面上 navigator.languages 可以被扩展或启动参数覆盖，
    但配置文件里的这个值才是源头。"""
    home = os.path.expanduser("~")
    cands = []
    if IS_WIN:
        la = os.environ.get("LOCALAPPDATA", "")
        cands = [
            (os.path.join(la, r"Google\Chrome\User Data\Default\Preferences"), "Chrome"),
            (os.path.join(la, r"Microsoft\Edge\User Data\Default\Preferences"), "Edge"),
            (os.path.join(la, r"BraveSoftware\Brave-Browser\User Data\Default\Preferences"), "Brave"),
        ]
    elif IS_MAC:
        cands = [
            (os.path.join(home, "Library/Application Support/Google/Chrome/Default/Preferences"), "Chrome"),
            (os.path.join(home, "Library/Application Support/Microsoft Edge/Default/Preferences"), "Edge"),
        ]
    else:
        cands = [
            (os.path.join(home, ".config/google-chrome/Default/Preferences"), "Chrome"),
            (os.path.join(home, ".config/chromium/Default/Preferences"), "Chromium"),
        ]
    found = []
    for p, name in cands:
        try:
            if not os.path.isfile(p):
                continue
            with open(p, encoding="utf-8", errors="replace") as f:
                txt = f.read(400000)
            m = re.search(r'"accept_languages"\s*:\s*"([^"]*)"', txt)
            if m:
                found.append((name, m.group(1)))
        except Exception:
            continue
    return found


def check_ntp():
    if IS_WIN:
        v = reg_read(r"HKLM\SYSTEM\CurrentControlSet\Services\W32Time\Parameters", "NtpServer")
        return str(v).split(",")[0] if v else "(未配置)"
    if IS_MAC:
        raw = run("systemsetup -getnetworktimeserver")
        return raw.split(":", 1)[1].strip() if ":" in raw else "(未配置)"
    return "(未知)"


def check_screen():
    if IS_WIN:
        try:
            import ctypes
            u = ctypes.windll.user32
            u.SetProcessDPIAware()
            return "%dx%d" % (u.GetSystemMetrics(0), u.GetSystemMetrics(1))
        except Exception:
            return "(读取失败)"
    if IS_MAC:
        raw = run("system_profiler SPDisplaysDataType")
        m = re.search(r'Resolution:\s*(\d+)\s*x\s*(\d+)', raw)
        if m:
            return "%sx%s" % (m.group(1), m.group(2))
    return "(未知)"


def check_location():
    """Windows 定位服务开关。开着意味着系统层面会暴露位置。"""
    if IS_WIN:
        v = reg_read(r"HKCU\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager"
                     r"\ConsentStore\location", "Value")
        return "允许" if str(v) == "Allow" else ("禁止" if str(v) == "Deny" else "(未知)")
    if IS_MAC:
        raw = run("defaults read /var/db/locationd/Library/Preferences/ByHost/"
                  "com.apple.locationd.notbackedup 2>/dev/null")
        return "(需权限)" if not raw else "已配置"
    return "(未知)"


def check_tz_auto():
    if IS_WIN:
        v = reg_read(r"HKLM\SYSTEM\CurrentControlSet\Services\tzautoupdate", "Start")
        return "自动" if str(v) == "3" else "手动"
    if IS_MAC:
        v = run("defaults read /Library/Preferences/com.apple.timezone.auto Active")
        return "自动" if "1" in v else "(未知)"
    return "(未知)"


def check_username():
    """Windows 用户名含中文，是个相当隐蔽的环境特征。"""
    u = os.path.expanduser("~").split(os.sep)[-1]
    return u, bool(re.search(r'[\u4e00-\u9fa5]', u))


# ---------------------------------------------------------------- 网络
def public_ip(timeout=2.0):
    """两个接口并发查，任何一个回来就用；总耗时封顶 timeout+0.5 秒。"""
    import urllib.request
    import threading
    urls = ("https://ipapi.co/json/", "http://ip-api.com/json/?lang=zh-CN")
    results = {}

    def fetch(u):
        try:
            req = urllib.request.Request(u, headers={"User-Agent": "curl/8"})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                d = json.loads(r.read().decode("utf-8", "replace"))
            results[u] = {
                "ip": d.get("ip", ""),
                "country": d.get("country_name") or d.get("country", ""),
                "code": d.get("country_code") or d.get("countryCode", ""),
                "city": d.get("city", ""),
                "org": d.get("org") or d.get("asn", ""),
                "tz": d.get("timezone", ""),
            }
        except Exception:
            pass

    ts = [threading.Thread(target=fetch, args=(u,), daemon=True) for u in urls]
    for t in ts:
        t.start()
    for t in ts:
        t.join(timeout + 0.5)
    for u in urls:
        if u in results:
            return results[u]
    return None


# ---------------------------------------------------------------- 检测项组装
def build_checks(offline=False):
    """返回 (items, meta)。items: [{g,name,state,weight,value,note,conf}]"""
    items = []

    # 联网项与本地检测并行跑，否则光等出口 IP 就要 6 秒
    _ip = {"done": False, "data": None}
    _ipthread = None
    if not offline:
        import threading

        def _fetch_ip():
            try:
                _ip["data"] = public_ip()
            except Exception:
                _ip["data"] = None
            _ip["done"] = True

        _ipthread = threading.Thread(target=_fetch_ip, daemon=True)
        _ipthread.start()

    tz, off = get_tz()
    loc, langs = get_locale()
    datefmt = fmt_date_sample()
    total_fonts, cjk_fonts, font_samples = count_fonts()
    relay = check_env_relay()
    proxy = check_proxy_env()
    tun = check_tun()
    hosts = check_hosts()
    dns = get_dns()
    cfiles = check_claude_files()
    proc = check_claude_process()
    user, user_cn = check_username()
    codepage = check_codepage()
    install_lang = check_install_lang()
    ime = check_ime()
    cn_soft = check_cn_software()
    netif = check_netif()
    kbd = check_keyboard()
    blang = check_browser_lang()
    ntp = check_ntp()
    scr = check_screen()
    loc_svc = check_location()
    tzauto = check_tz_auto()

    def add(g, name, hit, weight, value, note, conf="corr"):
        items.append({"g": g, "name": name, "hit": bool(hit), "w": weight,
                      "value": value, "note": note, "conf": conf})

    # ---- A 系统时间与区域 ----
    tz_cn = tz in ("Asia/Shanghai", "Asia/Urumqi", "China Standard Time")
    add("A. 系统时间与区域", "OS 时区", tz_cn, 18,
        tz or "(未取到)",
        "这是 Claude Code 真正读的那个值（不是浏览器里的）" if tz_cn else "不在已知检测名单内",
        "exact")

    add("A. 系统时间与区域", "UTC 偏移", off == 8, 5,
        "UTC%s%s" % ("+" if (off or 0) >= 0 else "", off),
        "与东八区一致" if off == 8 else "不是 UTC+8")

    loc_cn = "zh" in (loc or "").lower()
    add("A. 系统时间与区域", "系统区域设置", loc_cn, 8,
        loc or "(未知)",
        "系统区域指向中文" if loc_cn else "系统区域非中文")

    any_zh = any(str(x).lower().startswith("zh") for x in langs) or loc_cn
    add("A. 系统时间与区域", "系统语言", any_zh, 6,
        ", ".join(langs) if langs else "(取不到)",
        "语言栈含中文" if any_zh else "语言栈不含中文")

    date_cn = bool(re.search(r'年.*月.*日', datefmt or ""))
    add("A. 系统时间与区域", "本地日期格式", date_cn, 4,
        datefmt or "(取不到)",
        "输出中文式日期" if date_cn else "输出非中文日期格式")

    cp_cn = (codepage == "936")
    add("A. 系统时间与区域", "非 Unicode 程序代码页", cp_cn, 7,
        codepage,
        "936 = 简体中文（GBK）。这一项改区域设置也不会变，得改系统代码页" if cp_cn
        else "不是 936，说明系统不是简体中文环境")

    il_cn = "zh" in (install_lang or "").lower() or "中文" in (install_lang or "")
    add("A. 系统时间与区域", "系统安装语言", il_cn, 6,
        install_lang,
        "装系统时选的语言，后期改区域不会同步改这里" if il_cn else "安装语言非中文")

    add("A. 系统时间与区域", "中文输入法", bool(ime), 5,
        ("已启用 " + str(len(ime)) + " 个") if ime else "未发现",
        "输入法列表里含中文键盘（0804/0804 相关）" if ime else "没装中文输入法——干净环境的特征")

    # ---- B 中转与代理（浏览器完全读不到）----
    add("B. 中转与代理", "ANTHROPIC_BASE_URL", bool(relay["base"]), 14,
        relay["base"] or "(未设置)",
        "设置了非官方端点——这正是那 3 bit 检测的前置条件" if relay["base"]
        else "未设置，检测代码会直接返回 false",
        "exact")

    add("B. 中转与代理", "端点 hostname 命中已知中转", bool(relay["known"]), 8,
        relay["host"] or "(无)",
        ("命中关键词：" + "、".join(relay["known"])) if relay["known"] else "未命中已知关键词（不代表安全，名单有 147 条）")

    add("B. 中转与代理", "端点 hostname 含 AI 实验室词", bool(relay["lab"]), 5,
        relay["host"] or "(无)",
        ("命中：" + "、".join(relay["lab"])) if relay["lab"] else "未命中")

    has_proxy = any(k for k in proxy if "NO_PROXY" not in k)
    add("B. 中转与代理", "代理环境变量", has_proxy, 6,
        "; ".join("%s=%s" % (k, v) for k, v in proxy.items() if "NO_PROXY" not in k) or "(未设置)",
        "存在代理变量，终端流量会走它" if has_proxy else "未设置代理变量，终端可能直连")

    no_proxy_hit = any("anthropic" in (v or "").lower() for k, v in proxy.items() if "NO_PROXY" in k)
    add("B. 中转与代理", "NO_PROXY 是否放行了 anthropic", no_proxy_hit, 4,
        str(proxy.get("NO_PROXY") or proxy.get("no_proxy") or "(无)"),
        "放行了——这部分流量会绕过代理直连" if no_proxy_hit else "未放行")

    add("B. 中转与代理", "TUN / 虚拟网卡", bool(tun), 7,
        "; ".join(tun[:3]) if tun else "(未发现)",
        "找到虚拟网卡，全局代理可能已开" if tun else "没找到虚拟网卡，可能是规则模式或没开代理")

    add("B. 中转与代理", "hosts 篡改", bool(hosts), 4,
        "; ".join(hosts[:2]) if hosts else "(无相关条目)",
        "hosts 里有 anthropic/claude 相关改写" if hosts else "无相关条目")

    # ---- C Claude 客户端痕迹 ----
    add("C. Claude 客户端痕迹", "~/.claude 目录", cfiles.get("~/.claude 目录"), 3,
        "存在" if cfiles.get("~/.claude 目录") else "不存在",
        "本机跑过 Claude Code" if cfiles.get("~/.claude 目录") else "未发现 Claude Code 痕迹")

    settings_val = cfiles.get("settings.json")
    add("C. Claude 客户端痕迹", "settings.json", bool(settings_val), 3,
        str(settings_val), "存在配置文件" if settings_val else "无配置文件")

    if cfiles.get("settings 内的 base_url"):
        add("C. Claude 客户端痕迹", "配置里的 base_url", True, 6,
            cfiles["settings 内的 base_url"],
            "配置文件里写死了中转地址——比环境变量更持久", "exact")

    add("C. Claude 客户端痕迹", "Claude 桌面客户端", cfiles.get("Claude 桌面客户端"), 2,
        "已安装" if cfiles.get("Claude 桌面客户端") else "未安装",
        "装了客户端就多一个登录入口，也多一组设备指纹")

    add("C. Claude 客户端痕迹", "Claude 进程在运行", proc, 2,
        "是" if proc else "否",
        "当前有 claude 相关进程在跑" if proc else "当前没有 claude 进程")

    add("C. Claude 客户端痕迹", "登录用户名含中文", user_cn, 4,
        user, "用户名本身会出现在路径里，是个很隐蔽的特征" if user_cn else "用户名是 ASCII")

    # ---- D 字体与环境 ----
    add("D. 字体与环境", "系统中文字体", cjk_fonts > 0, 9,
        "%d 个中文字体 / 共 %d 个字体文件" % (cjk_fonts, total_fonts),
        ("样本：" + "、".join(font_samples)) if font_samples else "未发现中文字体——干净环境的典型特征")

    add("D. 字体与环境", "系统平台", platform.system() == "Windows", 3,
        "%s %s" % (platform.system(), platform.release()),
        "Windows 是中文用户最常见的桌面环境，但全球同样常见", "weak")

    add("D. 字体与环境", "已安装国产软件", bool(cn_soft), 6,
        ("、".join(cn_soft)) if cn_soft else "(未检出)",
        "软件列表是最难伪装的一层：不会有人为了用 Claude 把微信卸了" if cn_soft
        else "没检出常见的国产软件")

    add("D. 字体与环境", "网卡数量", netif > 2, 2,
        str(netif) + " 个",
        "网卡偏多通常意味着有虚拟网卡（代理 TUN）", "weak")

    add("D. 字体与环境", "默认键盘布局", "中文" in str(kbd), 3,
        kbd,
        "键盘布局跟着输入习惯走，改区域设置不会变" if "中文" in str(kbd) else "非中文键盘布局")

    dns_cn = any(d.startswith(("114.", "223.", "119.", "180.76")) for d in dns)
    add("D. 字体与环境", "DNS 服务器", dns_cn, 2,
        ", ".join(dns) if dns else "(取不到)",
        "用的是国内公共 DNS" if dns_cn else ("未拿到 DNS 列表" if not dns else "非典型国内公共 DNS"),
        "weak")

    # ---- E 出口（可选联网）----
    ipinfo = None
    if not offline and _ipthread is not None:
        _ipthread.join(timeout=2.5)
        ipinfo = _ip["data"] if _ip.get("done") else None
    if ipinfo:
        cn = ipinfo.get("code") == "CN"
        add("E. 出口 IP", "公网出口归属", cn, 12,
            "%s %s / %s / %s" % (ipinfo.get("ip", ""), ipinfo.get("country", ""),
                                 ipinfo.get("city", ""), ipinfo.get("org", "")),
            "出口在中国大陆——这不是指纹问题，是位置问题" if cn else "出口不在中国大陆",
            "exact")
        tzmatch = ipinfo.get("tz") and tz and ipinfo["tz"] == tz
        add("E. 出口 IP", "出口时区与系统时区", not tzmatch, 3,
            "%s vs %s" % (ipinfo.get("tz") or "?", tz or "?"),
            "两者不一致——这是最容易暴露的组合" if not tzmatch else "两者一致")
    else:
        add("E. 出口 IP", "公网出口归属", False, 0,
            "2.5 秒内没取到（网络不通或被墙）" if not offline else "已跳过（--offline）",
            "这一项没查成功，不计入分数；其余 31 项照常有效")

    # ---- F 只有桌面端能读到 ----
    if blang:
        zh_first = any(v.lower().startswith("zh") for _, v in blang)
        add("F. 只有桌面端能读", "浏览器真实语言偏好", zh_first, 6,
            "; ".join("%s: %s" % (n, v) for n, v in blang),
            "这是浏览器配置文件里的源头值。网页上的 navigator.languages 可以被扩展覆盖，"
            "这个不会——它才是 Accept-Language 真正的出处" if zh_first else "浏览器语言偏好非中文",
            "exact")
    else:
        add("F. 只有桌面端能读", "浏览器真实语言偏好", False, 4,
            "(未找到 Chrome/Edge 配置)",
            "没读到配置文件。可能是没装、没启动过，或者装在非默认路径")

    add("F. 只有桌面端能读", "NTP 时间服务器", bool(re.search(r'aliyun|cn\.pool|cn\.ntp|ntp\.ntsc', ntp, re.I)), 3,
        ntp,
        "配了国内 NTP 源" if re.search(r'aliyun|cn\.pool|cn\.ntp|ntp\.ntsc', ntp, re.I) else "非国内 NTP 源",
        "weak")

    add("F. 只有桌面端能读", "屏幕分辨率（OS 层）", scr in ("1920x1080", "1366x768", "2560x1440", "3840x2160"), 2,
        scr, "常见桌面分辨率，单独看几乎没有区分度", "weak")

    add("F. 只有桌面端能读", "系统定位服务", loc_svc == "允许", 4,
        loc_svc,
        "定位开着，系统层面会记录你的位置" if loc_svc == "允许" else "定位已关闭或未开启")

    add("F. 只有桌面端能读", "时区自动设置", tzauto == "手动", 3,
        tzauto,
        "手动设的时区——如果这个值和你所在的地方对不上，说明有人动过" if tzauto == "手动"
        else "自动跟随网络，改起来更麻烦")

    meta = {
        "tz": tz, "off": off, "loc": loc, "langs": langs, "datefmt": datefmt,
        "cjk_fonts": cjk_fonts, "total_fonts": total_fonts,
        "relay": relay, "proxy": proxy, "tun": tun, "ipinfo": ipinfo,
        "offline": bool(offline), "ip_failed": (not offline and ipinfo is None),
        "user": user, "user_cn": user_cn, "platform": platform.platform(),
        "python": sys.version.split()[0], "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
    }
    return items, meta


# ---------------------------------------------------------------- 一致性诊断
def diagnose(items, meta):
    out = []
    tz = meta["tz"] or ""
    loc = meta["loc"] or ""
    relay = meta["relay"]
    ip = meta["ipinfo"]
    tz_cn = tz in ("Asia/Shanghai", "Asia/Urumqi", "China Standard Time")
    loc_cn = "zh" in loc.lower()

    def add(level, title, left, right, why):
        out.append({"level": level, "title": title, "left": left, "right": right, "why": why})

    # 1 时区 vs 区域
    if tz_cn and not loc_cn:
        add("warn", "时区在中国，系统区域却是外语", "时区 " + tz, "区域 " + loc,
            "改了一半。系统时区没动但区域改了，或者反过来——这种局部改动本身就是异常特征。")
    elif not tz_cn and loc_cn:
        add("warn", "时区在海外，系统区域仍是中文", "时区 " + tz, "区域 " + loc,
            "人在海外的话正常。但如果出口 IP 与你在国内的行为习惯对不上，这一项会被翻出来。")
    elif tz_cn and loc_cn:
        add("bad", "时区与区域双双指向中国", "时区 " + tz, "区域 " + loc,
            "两项都是 OS 层的真值，改起来要动系统设置，很少有人在改。这是最硬的组合。")
    else:
        add("ok", "时区与区域一致（均非中国）", "时区 " + tz, "区域 " + loc, "自洽。")

    # 2 时区 vs 中文字体
    if tz_cn and meta["cjk_fonts"] == 0:
        add("warn", "时区在中国，却没有中文字体", "时区 " + tz, "中文字体 0 个",
            "系统里没有中文字体，但时区是中国的——通常意味着这是个被清理过的环境。")
    elif not tz_cn and meta["cjk_fonts"] > 5:
        add("warn", "时区在海外，却装了 " + str(meta["cjk_fonts"]) + " 个中文字体",
            "时区 " + tz, "中文字体 " + str(meta["cjk_fonts"]) + " 个",
            "字体是「你到底用不用中文」的物理证据。语言能改，字体很少有人删。")
    else:
        add("ok", "字体与时区不冲突", "时区 " + tz, "中文字体 " + str(meta["cjk_fonts"]) + " 个", "未发现冲突。")

    # 3 中转端点
    if relay["base"]:
        if relay["known"]:
            add("bad", "中转 hostname 命中已知名单", "BASE_URL " + relay["base"],
                "命中 " + "、".join(relay["known"]),
                "这是那 3 bit 检测里 `known` 位的直接来源。只要设了这个 base_url，检测的前置条件就已经满足。")
        else:
            add("warn", "设置了自定义 base_url", "BASE_URL " + relay["base"], "未命中关键词",
                "未命中不代表安全——已知名单有 147 条域名，关键词匹配只是粗筛。")
    else:
        add("ok", "未设置 base_url", "BASE_URL (空)", "检测前置条件不成立",
            "逆向报告里，没设这个变量时检测函数直接返回 false。这是最省事的一项。")

    # 4 代理 vs TUN
    has_proxy = any(k for k in meta["proxy"] if "NO_PROXY" not in k)
    if has_proxy and not meta["tun"]:
        add("warn", "设了代理变量，但没找到 TUN 网卡", "代理变量已设置", "TUN 未发现",
            "很可能是规则模式。规则模式下有一部分流量会直连，包括 Claude Code 的某些请求和 DNS。")
    elif meta["tun"] and not has_proxy:
        add("ok", "开了 TUN，没设代理变量", "TUN 已发现", "环境变量为空",
            "这是全局代理的正常形态：系统级路由接管，不需要环境变量。")
    elif meta["tun"] and has_proxy:
        add("ok", "TUN 与代理变量同时存在", "TUN 已发现", "代理变量已设置", "全局代理，形态正确。")
    else:
        add("warn", "既没有 TUN 也没有代理变量", "无", "无",
            "终端流量很可能直连。在 Claude Code 里这意味着真实 IP 直接暴露。")

    # 5 出口 vs 系统时区
    if ip:
        if ip.get("code") == "CN":
            add("bad", "出口 IP 在中国大陆", "IP " + ip.get("ip", ""), "系统时区 " + tz,
                "到这一步，指纹已经不重要了。位置本身就是判定依据。")
        elif ip.get("tz") and tz and ip["tz"] != tz:
            add("bad", "出口时区与系统时区不一致", "出口 " + ip["tz"], "系统 " + tz,
                "「IP 在美西、时区在东八区」是最经典的矛盾组合之一。")
        else:
            add("ok", "出口与系统时区一致", "出口 " + (ip.get("tz") or "?"), "系统 " + tz, "自洽。")
    else:
        if meta.get("offline"):
            add("ok", "出口未查询", "—", "—", "用了 --offline，跳过这一项。")
        else:
            add("ok", "出口未查询", "—", "—",
                "联网查询没成功（2.5 秒超时或接口被墙）。这一项不参与判定，其余 31 项照常。")

    bad = sum(1 for x in out if x["level"] == "bad")
    warn = sum(1 for x in out if x["level"] == "warn")
    if bad == 0 and warn == 0:
        add("ok", "整体：信号自洽", "0 硬 / 0 软", "—", "所有信号能互相解释。这比分数低更重要。")
    elif bad == 0:
        add("warn", "整体：%d 处软矛盾" % warn, "硬矛盾 0", "软矛盾 %d" % warn,
            "每项单独看都能解释，合起来会削弱可信度。")
    else:
        add("bad", "整体：%d 处硬矛盾" % bad, "硬矛盾 %d" % bad, "软矛盾 %d" % warn,
            "硬矛盾指的是物理上不可能同时成立的组合。这种状态比全都不改更危险。")
    return out


# ---------------------------------------------------------------- 评分
def score(items):
    total = sum(i["w"] for i in items) or 1
    got = sum(i["w"] for i in items if i["hit"])
    return got, total, round(got / total * 100)


def grade(p):
    if p >= 61:
        return "high", "高", "多项信号在讲同一件事：这台机器大概率在中国大陆。"
    if p >= 31:
        return "mid", "中", "有几项在说同一件事，但还不算铁证。"
    return "low", "低", "至少在这些信号里，没露出明显破绽。"


# ---------------------------------------------------------------- 输出：CLI
C = {
    "reset": "\033[0m", "dim": "\033[2m", "b": "\033[1m",
    "red": "\033[31m", "green": "\033[32m", "yellow": "\033[33m",
    "blue": "\033[36m", "gray": "\033[90m",
}


def color(s, c):
    if IS_WIN and not os.environ.get("WT_SESSION"):
        return s
    return C.get(c, "") + s + C["reset"]


def print_cli(items, diag, meta):
    got, total, p = score(items)
    cls, label, txt = grade(p)

    print()
    print(color("  Fuck Claude — Desktop Scanner v" + APP_VER, "b"))
    print(color("  桌面端检测 · 浏览器读不到的那些项", "gray"))
    print()
    print("  " + color("风险分 %d/100" % p, "red" if p >= 61 else "yellow" if p >= 31 else "green")
          + color("  （命中加权 %d/%d）" % (got, total), "gray"))
    print("  " + txt)
    print()

    last = ""
    for it in items:
        if it["g"] != last:
            last = it["g"]
            print(color("\n  " + last, "b"))
        mark = color("✕", "red") if it["hit"] else color("✓", "green")
        w = color("(%d)" % it["w"], "gray")
        print("   %s %-28s %s  %s" % (mark, it["name"], w, it["value"]))
        if it["note"]:
            print("     " + color(it["note"], "gray"))

    print(color("\n  一致性诊断", "b"))
    for d in diag:
        m = {"bad": color("✕ 硬矛盾", "red"), "warn": color("! 软矛盾", "yellow"),
             "ok": color("✓ 自洽", "green")}[d["level"]]
        print("   %s %s" % (m, d["title"]))
        print("     " + color(d["why"], "gray"))

    print()
    print(color("  报告已保存：", "gray") + meta.get("_report", "(仅终端输出)"))
    print()


# ---------------------------------------------------------------- 输出：HTML
HTML_TPL = """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fuck Claude — 桌面检测报告</title>
<style>
:root{--bg:#060a13;--panel:#0b1220;--line:#1a2440;--text:#c8d4ea;--text2:#8b9bc0;--dim:#4a5578;
--accent:#4d9fff;--ok:#3ddc97;--warn:#ffb020;--danger:#ff4d6d;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--mono);font-size:14px;line-height:1.65;
background-image:radial-gradient(900px 420px at 50% -100px,rgba(77,159,255,.10),transparent 70%)}
.wrap{max-width:820px;margin:0 auto;padding:34px 20px 70px}
h1{font-size:26px;color:#fff;margin:0 0 6px}
.sub{color:var(--dim);font-size:12px;margin-bottom:26px}
.score{font-size:66px;font-weight:800;line-height:1;letter-spacing:-.04em}
.score.high{color:var(--danger)}.score.mid{color:var(--warn)}.score.low{color:var(--ok)}
.score small{font-size:15px;color:var(--dim);font-weight:400}
.verdict{color:var(--text2);font-size:13px;margin-top:8px}
.card{border:1px solid var(--line);background:linear-gradient(180deg,var(--panel),#080d18);
border-radius:6px;margin-top:26px;overflow:hidden}
.card h2{font-size:11px;letter-spacing:.14em;color:var(--text2);margin:0;padding:11px 16px;
border-bottom:1px solid var(--line);background:rgba(255,255,255,.015);font-weight:600}
.row{display:flex;justify-content:space-between;gap:12px;padding:10px 16px;
border-bottom:1px solid rgba(26,36,64,.6);align-items:flex-start}
.row:last-child{border-bottom:0}
.row .n{font-size:12.5px}
.row .v{font-size:11.5px;color:var(--text2);text-align:right;word-break:break-all;max-width:52%}
.row .note{font-size:11px;color:var(--dim);padding:0 16px 10px;line-height:1.6}
.tag{font-size:10px;padding:1px 6px;border-radius:2px;margin-left:8px}
.tag.hit{color:var(--danger);background:rgba(255,77,109,.1);border:1px solid rgba(255,77,109,.3)}
.tag.miss{color:var(--ok);background:rgba(61,220,151,.08);border:1px solid rgba(61,220,151,.25)}
.dg{padding:12px 16px;border-bottom:1px solid rgba(26,36,64,.6)}
.dg:last-child{border-bottom:0}
.dg .t{font-size:12.5px;display:flex;justify-content:space-between;gap:10px}
.dg .w{font-size:11.5px;color:var(--dim);margin-top:5px;line-height:1.6}
.bad{color:var(--danger)}.warn{color:var(--warn)}.ok{color:var(--ok)}
.foot{margin-top:30px;color:var(--dim);font-size:11px;line-height:1.9}
.meta{color:var(--dim);font-size:11px;display:flex;gap:18px;flex-wrap:wrap;margin-top:14px}
</style></head><body><div class="wrap">
<h1>Fuck Claude — 桌面检测报告</h1>
<div class="sub">32 项信号 · 8 组一致性诊断 · 全部本地运行</div>

<div class="score @@cls@@">@@p@@<small>/100</small></div>
<div class="verdict">风险等级：@@label@@ — @@txt@@</div>
<div class="meta"><span>主机 @@host@@</span><span>@@system@@</span><span>Python @@pyver@@</span><span>@@when@@</span></div>

@@items_html@@

<div class="card"><h2>一致性诊断</h2>@@diag_html@@</div>

<div class="foot">
本报告由本地程序生成，数据没有上传到任何地方。<br>
检测逻辑基于公开逆向分析报告，不构成任何官方结论。封号是概率事件，不是阈值判定。<br>
<span style="color:#6b7a9e">Fuck Claude Desktop v@@ver@@ · 生成于 @@when@@</span>
</div>
</div></body></html>
"""


def build_html(items, diag, meta):
    got, total, p = score(items)
    cls, label, txt = grade(p)
    BADGE = {"bad": ("bad", "✕ 硬矛盾"), "warn": ("warn", "! 软矛盾"), "ok": ("ok", "✓ 自洽")}

    parts, last = [], ""
    for it in items:
        if it["g"] != last:
            if last:
                parts.append("</div>")
            last = it["g"]
            parts.append('<div class="card"><h2>%s</h2>' % html.escape(last))
        tag = '<span class="tag hit">命中</span>' if it["hit"] else '<span class="tag miss">未命中</span>'
        parts.append(
            '<div class="row"><div class="n">%s%s</div><div class="v">%s</div></div>'
            '<div class="note">%s</div>' % (
                html.escape(it["name"]), tag, html.escape(str(it["value"])), html.escape(it["note"] or "")))
    if last:
        parts.append("</div>")

    dg = []
    for d in diag:
        c, lab = BADGE[d["level"]]
        dg.append('<div class="dg"><div class="t"><span>%s</span><span class="%s">%s</span></div>'
                  '<div class="w">%s</div></div>' % (
                      html.escape(d["title"]), c, lab, html.escape(d["why"])))

    subs = {
        "p": str(p), "cls": cls, "label": label, "txt": html.escape(txt),
        "host": html.escape(socket.gethostname()),
        "system": html.escape(meta["platform"]), "pyver": meta["python"],
        "when": meta["time"], "ver": APP_VER,
        "items_html": "\n".join(parts), "diag_html": "\n".join(dg),
    }
    out = HTML_TPL
    for k, v in subs.items():
        out = out.replace("@@" + k + "@@", v)
    return out


# ---------------------------------------------------------------- GUI
GUI_BG     = "#060a13"
GUI_PANEL  = "#0b1220"
GUI_BORDER = "#1b2740"
GUI_TXT    = "#c3d3e6"
GUI_DIM    = "#6b7a9e"
GUI_RED    = "#ff5f56"
GUI_YEL    = "#ffbd2e"
GUI_GRN    = "#27c93f"
GUI_ACC    = "#4da3ff"


def _hide_console():
    if not IS_WIN:
        return
    try:
        import ctypes
        hwnd = ctypes.windll.kernel32.GetConsoleWindow()
        if hwnd:
            ctypes.windll.user32.ShowWindow(hwnd, 0)
    except Exception:
        pass


def _console_and_pause():
    try:
        input("\n按回车键退出 …")
    except Exception:
        pass


def run_gui(selftest=False):
    """tkinter 主界面。selftest=True 时只构建一次界面然后自动关闭，用于打包冒烟测试。"""
    import threading
    import queue as _q
    try:
        import tkinter as tk
        from tkinter import filedialog, messagebox
    except Exception as e:
        print("图形界面不可用：%s" % e)
        _console_and_pause()
        return

    if IS_WIN:
        try:
            import ctypes
            try:
                ctypes.windll.shcore.SetProcessDpiAwareness(1)
            except Exception:
                ctypes.windll.user32.SetProcessDPIAware()
        except Exception:
            pass
        if not selftest:
            _hide_console()

    root = tk.Tk()
    root.title("Fuck Claude · 桌面端检测 v%s" % APP_VER)
    root.geometry("1000x760")
    root.minsize(860, 620)
    root.configure(bg=GUI_BG)

    mf = "Consolas" if IS_WIN else "Menlo"
    uf = "Microsoft YaHei UI" if IS_WIN else "PingFang SC"

    state = {"items": None, "meta": None, "diag": None,
             "busy": False, "offline": False, "retried": False}

    # ---------- 顶部标题
    top = tk.Frame(root, bg=GUI_BG)
    top.pack(fill="x", padx=22, pady=(16, 0))
    tk.Label(top, text="FUCK", font=(mf, 19, "bold"), fg="#e8eef8", bg=GUI_BG).pack(side="left")
    tk.Label(top, text="CLAUDE", font=(mf, 19, "bold"), fg=GUI_RED, bg=GUI_BG).pack(side="left")
    tk.Label(top, text="   桌面端 · 本机 32 项检测", font=(uf, 9),
             fg=GUI_DIM, bg=GUI_BG).pack(side="left", pady=(5, 0))
    host_lbl = tk.Label(top, text="", font=(mf, 8), fg=GUI_DIM, bg=GUI_BG)
    host_lbl.pack(side="right", pady=(7, 0))

    # ---------- 分数卡
    sc = tk.Frame(root, bg=GUI_PANEL, highlightthickness=1,
                  highlightbackground=GUI_BORDER, highlightcolor=GUI_BORDER)
    sc.pack(fill="x", padx=22, pady=(13, 0))
    inner = tk.Frame(sc, bg=GUI_PANEL)
    inner.pack(fill="x", padx=18, pady=13)

    num = tk.Label(inner, text="--", font=(mf, 42, "bold"), fg=GUI_DIM, bg=GUI_PANEL)
    num.pack(side="left")
    tk.Label(inner, text="/100", font=(mf, 11), fg=GUI_DIM, bg=GUI_PANEL).pack(side="left", pady=(20, 0))

    vb = tk.Frame(inner, bg=GUI_PANEL)
    vb.pack(side="left", padx=(18, 0), pady=(4, 0), fill="x", expand=True)

    verdict = tk.Label(vb, text="正在扫描本机环境 …", font=(uf, 11, "bold"),
                       fg=GUI_TXT, bg=GUI_PANEL, anchor="w")
    verdict.pack(fill="x")

    vsub = tk.Label(vb, text="读注册表、字体、网卡、环境变量；联网项约 3 秒。",
                    font=(uf, 9), fg=GUI_DIM, bg=GUI_PANEL, anchor="w",
                    justify="left", wraplength=640)
    vsub.pack(fill="x", pady=(3, 0))

    bar = tk.Frame(vb, bg="#16233a", height=4)
    bar.pack(fill="x", pady=(9, 0))
    fill = tk.Frame(bar, bg=GUI_ACC, height=4)
    fill.place(x=0, y=0, relwidth=0, height=4)

    # ---------- 正文
    body = tk.Frame(root, bg=GUI_BG)
    body.pack(fill="both", expand=True, padx=22, pady=(13, 0))

    txt = tk.Text(body, bg=GUI_PANEL, fg=GUI_TXT, font=(uf, 10), relief="flat",
                  wrap="word", padx=15, pady=11, insertwidth=0, cursor="arrow",
                  highlightthickness=1, highlightbackground=GUI_BORDER,
                  spacing1=1, spacing3=1)
    sb = tk.Scrollbar(body, command=txt.yview)
    txt.configure(yscrollcommand=sb.set)
    sb.pack(side="right", fill="y")
    txt.pack(side="left", fill="both", expand=True)

    txt.tag_configure("h1", font=(uf, 11, "bold"), foreground="#e8eef8",
                      spacing1=12, spacing3=5)
    txt.tag_configure("row", font=(mf, 10), foreground=GUI_TXT)
    txt.tag_configure("note", font=(uf, 9), foreground=GUI_DIM,
                      lmargin1=26, lmargin2=26)
    txt.tag_configure("hit", font=(mf, 9, "bold"), foreground=GUI_RED)
    txt.tag_configure("miss", font=(mf, 9), foreground="#5d739b")
    txt.tag_configure("bad", font=(uf, 9, "bold"), foreground=GUI_RED)
    txt.tag_configure("warn", font=(uf, 9, "bold"), foreground=GUI_YEL)
    txt.tag_configure("ok", font=(uf, 9, "bold"), foreground=GUI_GRN)
    txt.tag_configure("why", font=(uf, 9), foreground=GUI_DIM,
                      lmargin1=26, lmargin2=26, spacing3=7)
    txt.tag_configure("sep", font=(mf, 8), foreground="#223049")

    # ---------- 底部按钮
    bt = tk.Frame(root, bg=GUI_BG)
    bt.pack(fill="x", padx=22, pady=(11, 14))

    def mkbtn(text, cmd, primary=False):
        b = tk.Button(bt, text=text, command=cmd, font=(uf, 9),
                      bg=GUI_ACC if primary else GUI_PANEL,
                      fg="#04101f" if primary else GUI_TXT,
                      activebackground=GUI_ACC, activeforeground="#04101f",
                      relief="flat", bd=0, padx=15, pady=6, cursor="hand2",
                      highlightthickness=0)
        b.pack(side="left", padx=(0, 8))
        return b

    def save_report():
        if not state["items"]:
            return
        page = build_html(state["items"], state["diag"], state["meta"])
        desk = os.path.join(os.path.expanduser("~"), "Desktop")
        p = filedialog.asksaveasfilename(
            defaultextension=".html", initialfile="fuck-claude-report.html",
            initialdir=desk if os.path.isdir(desk) else os.path.expanduser("~"),
            filetypes=[("HTML", "*.html"), ("所有文件", "*.*")])
        if not p:
            return
        try:
            with open(p, "w", encoding="utf-8") as f:
                f.write(page)
        except Exception as e:
            messagebox.showerror("保存失败", str(e))
            return
        if messagebox.askyesno("已保存", "报告已保存到：\n%s\n\n用浏览器打开？" % p):
            webbrowser.open("file://" + p.replace("\\", "/"))

    def render():
        items, meta, diag = state["items"], state["meta"], state["diag"]
        got, total, p = score(items)
        cls, label, vtxt = grade(p)
        col = GUI_RED if cls == "high" else (GUI_YEL if cls == "mid" else GUI_GRN)

        num.configure(text=str(p), fg=col)
        verdict.configure(text="风险等级：%s   ·   加权命中 %d / %d" % (label, got, total), fg=col)
        vsub.configure(text=vtxt)
        fill.configure(bg=col)
        fill.place_configure(relwidth=max(0.0, min(1.0, p / 100.0)))
        host_lbl.configure(text="%s  ·  %s" % (
            socket.gethostname(), str(meta.get("platform", ""))[:40]))

        t = txt
        t.configure(state="normal")
        t.delete("1.0", "end")

        if diag:
            BADGE = {"bad": ("bad", "✕ 硬矛盾"), "warn": ("warn", "! 软矛盾"),
                     "ok": ("ok", "✓ 自洽")}
            t.insert("end", "一致性诊断\n", "h1")
            for d in diag:
                c, lab = BADGE.get(d.get("level"), ("ok", "· 提示"))
                t.insert("end", lab + "  ", c)
                t.insert("end", str(d.get("title", "")) + "\n", "row")
                t.insert("end", str(d.get("why", "")) + "\n", "why")

        t.insert("end", "\n" + "─" * 56 + "\n", "sep")
        last = None
        for it in items:
            if it["g"] != last:
                last = it["g"]
                t.insert("end", last + "\n", "h1")
            if it["hit"]:
                t.insert("end", "● 命中    ", "hit")
            else:
                t.insert("end", "○ 未命中  ", "miss")
            t.insert("end", "%s  %s\n" % (it["name"], it["value"]), "row")
            note = it.get("note") or ""
            if note:
                t.insert("end", note + "\n", "note")

        t.configure(state="disabled")
        t.yview_moveto(0)

    q = _q.Queue()

    def worker(offline):
        try:
            items, meta = build_checks(offline=offline)
            q.put(("ok", items, meta))
        except Exception as e:
            q.put(("err", str(e), None))

    def poll():
        try:
            kind, a, b = q.get_nowait()
        except _q.Empty:
            root.after(120, poll)
            return
        if kind == "ok":
            state["items"], state["meta"] = a, b
            state["diag"] = diagnose(a, b)
            state["busy"] = False
            render()
            btn_scan.configure(state="normal", text="重新扫描")
        else:
            state["busy"] = False
            if not state["retried"]:
                state["retried"] = True
                state["offline"] = True
                start()
            else:
                verdict.configure(text="扫描失败：%s" % a, fg=GUI_RED)
                vsub.configure(text="可以在命令行加 --cli 看完整报错。")
                btn_scan.configure(state="normal", text="重新扫描")

    def start():
        if state["busy"]:
            return
        state["busy"] = True
        state["t0"] = time.time()
        btn_scan.configure(state="disabled", text="扫描中 …")
        verdict.configure(text="正在扫描本机环境 …", fg=GUI_TXT)
        vsub.configure(text="读注册表、字体、网卡、环境变量；联网项最多等 2.5 秒。")
        threading.Thread(target=worker, args=(state["offline"],), daemon=True).start()
        root.after(150, poll)
        tick()

    def tick():
        if not state["busy"]:
            return
        el = time.time() - state["t0"]
        if state["items"] is None:
            verdict.configure(text="正在扫描本机环境 …  %.1f 秒" % el)
        root.after(200, tick)

    btn_scan = mkbtn("重新扫描", start, primary=True)
    mkbtn("保存报告", save_report)
    mkbtn("网页版检测", lambda: webbrowser.open("https://jiangtian.top/claude/fuck-claude.html"))
    mkbtn("退出", root.destroy)
    tk.Label(bt, text="全部本机读取，不上传数据（仅出口 IP 一项会请求 ipapi.co）",
             font=(uf, 8), fg=GUI_DIM, bg=GUI_BG).pack(side="right", pady=(6, 0))

    if selftest:
        state["items"], state["meta"] = build_checks(offline=True)
        state["diag"] = diagnose(state["items"], state["meta"])
        render()
        root.update_idletasks()
        root.update()
        n = len(txt.get("1.0", "end").strip())
        root.destroy()
        print("selftest ok: %d items, %d diag, %d chars rendered"
              % (len(state["items"]), len(state["diag"]), n))
        return

    root.after(60, start)
    root.mainloop()


# ---------------------------------------------------------------- main
def main():
    # Windows 控制台默认 GBK，统一成 utf-8，避免中文和 JSON 乱码
    for stream in (sys.stdout, sys.stderr):
        try:
            if stream and (stream.encoding or "").lower() not in ("utf-8", "utf8"):
                stream.reconfigure(encoding="utf-8", errors="replace")
        except Exception:
            pass

    ap = argparse.ArgumentParser(description="Fuck Claude — 桌面端检测")
    ap.add_argument("--cli", action="store_true", help="仅终端输出")
    ap.add_argument("--json", action="store_true", help="输出 JSON")
    ap.add_argument("--no-open", action="store_true", help="生成报告但不打开浏览器")
    ap.add_argument("--offline", action="store_true", help="跳过联网查询")
    ap.add_argument("-o", "--out", default="", help="报告输出路径")
    ap.add_argument("--gui", action="store_true", help="打开图形界面（默认行为）")
    ap.add_argument("--selftest", action="store_true", help=argparse.SUPPRESS)
    args = ap.parse_args()

    if args.selftest:
        run_gui(selftest=True)
        return
    # 不带任何参数（双击运行）就是开窗口，不再靠控制台
    if args.gui or not (args.cli or args.json):
        run_gui()
        return

    items, meta = build_checks(offline=args.offline)
    diag = diagnose(items, meta)
    got, total, p = score(items)

    if args.json:
        print(json.dumps({"score": p, "hits": got, "total": total,
                          "items": items, "diagnosis": diag, "meta": meta},
                         ensure_ascii=False, indent=2))
        return

    out = args.out or os.path.join(
        os.path.expanduser("~"), "Desktop" if os.path.isdir(os.path.join(os.path.expanduser("~"), "Desktop"))
        else "", "fuck-claude-report.html")

    # 先渲染再落盘：渲染失败不会留下 0 字节的空报告
    try:
        page = build_html(items, diag, meta)
    except Exception as e:
        page = None
        meta["_report"] = "(报告生成失败: %s)" % e
    if page:
        try:
            with open(out, "w", encoding="utf-8") as f:
                f.write(page)
            meta["_report"] = out
        except Exception as e:
            meta["_report"] = "(写入失败: %s)" % e

    print_cli(items, diag, meta)

    if not args.no_open and not args.cli and page:
        try:
            webbrowser.open("file://" + out.replace("\\", "/"))
        except Exception:
            pass


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
    except Exception:
        # 双击运行时出错也要留下证据，不能一闪就没
        import traceback
        tb = traceback.format_exc()
        try:
            import tempfile
            logp = os.path.join(tempfile.gettempdir(), "fuck-claude-error.log")
            with open(logp, "w", encoding="utf-8") as f:
                f.write(tb)
        except Exception:
            logp = "(日志写入失败)"
        sys.stderr.write(tb)
        try:
            import tkinter as tk
            from tkinter import messagebox
            r = tk.Tk()
            r.withdraw()
            messagebox.showerror("Fuck Claude 出错了",
                                 "程序异常退出，详情已写入：\n%s\n\n%s"
                                 % (logp, tb.strip().splitlines()[-1][:200]))
            r.destroy()
        except Exception:
            _console_and_pause()
