diff --git a/full/proxy.py b/full/proxy.py index 034a763..8ac6ed0 100644 --- a/full/proxy.py +++ b/full/proxy.py @@ -1,2111 +1,2119 @@ -# -*- coding: utf-8 -*- - -import sys, os, subprocess, time, ctypes, threading, ssl as _ssl, base64 -from ctypes import wintypes -from pathlib import Path - -# 私有 HTTPS 端点通常使用自签名/内部证书,创建免校验 SSL 上下文 -# 用于:更新检查、PAC 拉取、tooltip 加载等程序内部 HTTP 请求 -_SSL_NO_VERIFY = _ssl._create_unverified_context() - -from PyQt5.QtWidgets import ( - QApplication, QWidget, QLabel, QPushButton, - QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, - QRadioButton, QButtonGroup, QCheckBox, QToolTip, -) -from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QEvent -from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor - -VERSION = "202607192300" -UPDATE_USER = "user" -UPDATE_PASSWORD = "ftA406u@all#2309" - -# 与 nginx 正向代理使用同一套 Basic 认证凭证 -_PROXY_AUTH_BASIC = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode() - -# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后, -# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。 -_BASE_WINDOW_WIDTH = 340 -_BASE_WINDOW_HEIGHT = 470 # 内容区 + 加大日志区 + 底部两行提示小字 - -# ── 配色(Qt StyleSheet 使用)──────────────────────────── -C_TITLE = "#8899aa" # 标题 / 副标题 -C_GREEN = "#51cf66" # 开启按钮、已连接状态 -C_YELLOW = "#ffd43b" # 提示色(保留) -C_RED = "#ff6b6b" # 关闭按钮、未连接 / 错误状态 -C_BG = "#ffffff" # 主背景 -C_CARD = "#f0f2f5" # 卡片背景 -C_BORDER = "#d0d5dd" # 边框 -C_TEXT = "#333333" # 正文 -C_SUBTEXT = "#889099" # 次要文字 - -# ── 路径 ────────────────────────────────────────────── -# frozen=True 表示已被 PyInstaller 打包为 EXE: -# BASE_DIR -> EXE 解压出的临时目录(_MEIPASS) -# PERSIST_DIR -> EXE 所在目录,服务安装等需要持久化的操作都放这里 -if getattr(sys, 'frozen', False): - BASE_DIR = Path(sys._MEIPASS) - PERSIST_DIR = Path(sys.executable).resolve().parent -else: - BASE_DIR = Path(__file__).resolve().parent - PERSIST_DIR = BASE_DIR - -WG_DIR = BASE_DIR / "wg" -WG_EXE = WG_DIR / "wireguard.exe" -WG_CLI = WG_DIR / "wg.exe" -WG_CONF = BASE_DIR / "jp_client.conf" -WG_SERVICE = "WireGuardTunnel$jp_client" - -# WireGuard 服务安装必须使用 PERSIST_DIR,不能依赖会被清理的 temp 目录 -_PERSIST_WG_EXE = PERSIST_DIR / "wg" / "wireguard.exe" -_PERSIST_WG_CONF = PERSIST_DIR / "jp_client.conf" - -def _endpoint_domain(): - """从 jp_client.conf 的 Endpoint 行解析服务器域名。""" - try: - for line in WG_CONF.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line.startswith("Endpoint "): - addr = line.split("=", 1)[1].strip() - return addr.rsplit(":", 1)[0] - except Exception: - pass - return "???" - -def _endpoint_port(default=52810): - """从 jp_client.conf 的 Endpoint 行解析服务器端口。""" - try: - for line in WG_CONF.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line.startswith("Endpoint "): - addr = line.split("=", 1)[1].strip() - return int(addr.rsplit(":", 1)[1]) - except Exception: - pass - return default - -def _listen_port(default=52810): - """从 jp_client.conf 的 ListenPort 行解析本地监听端口。""" - try: - for line in WG_CONF.read_text(encoding="utf-8").splitlines(): - line = line.strip() - if line.startswith("ListenPort "): - return int(line.split("=", 1)[1].strip()) - except Exception: - pass - return default - -def _server_ip(): - """把 endpoint 域名解析为 IPv4 地址,仅用于界面显示。""" - try: - import socket - return socket.getaddrinfo(_endpoint_domain(), 443, socket.AF_INET, socket.SOCK_STREAM)[0][4][0] - except Exception: - return "???" - -def _tcp_endpoint_reachable(port=80, timeout=5): - """测试能否通过 TCP 连到服务器,用于区分"TCP 通但 UDP 被拦截"的场景。""" - try: - import socket - ip = socket.getaddrinfo(_endpoint_domain(), port, socket.AF_INET, socket.SOCK_STREAM)[0][4][0] - with socket.create_connection((ip, port), timeout=timeout): - return True - except Exception: - return False - -# ── 本地认证转发器(直连 / WG 模式均使用) ───────────── -# 本地 127.0.0.1:1080 作为透明 TCP 转发器, -# 直连模式通过 TLS 转发到远端服务器,WG 模式直接转发到 WG 网关。 -# 转发前会在首条 HTTP 请求中自动注入 Proxy-Authorization, -# 使 Windows 应用无需感知 nginx 正向代理的 Basic 认证。 -_TLS_LOCAL_PORT = 1080 -_tls_forwarder = None - -# 系统代理注册表路径,以及当前生效的代理地址 -PROXY_HOST = "127.0.0.1" -PROXY_PORT = _TLS_LOCAL_PORT -PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" - -# ── PAC 内存服务 ────────────────────────────────────── -# Windows 无法跳过 HTTPS 证书校验下载远程 PAC,因此在本地 127.0.0.1 -# 启动一个极简 HTTP 服务,把 PAC 内容放在内存里供系统读取。 -_pac_server = None # {"server": TCPServer, "port": int} - -# 服务器 PAC 拉取失败时的回退:直接内置服务器上的完整 PAC(直连版本) -# 内容来源: https://tunnel.rrrosmontis.icu/proxy.pac -# 实际使用时根据当前模式替换其中的代理地址 -_BUILT_IN_PAC_DIRECT = r'''function FindProxyForURL(url, host) { - // RRPP 规则代理列表 — 仅以下域名走加密隧道 - var proxy = "PROXY 127.0.0.1:1080"; - // Google 全系 + Gemini + CDN - if (shExpMatch(host, "google.com") || - shExpMatch(host, "*.google.com") || - shExpMatch(host, "google.com.hk") || - shExpMatch(host, "*.google.com.hk") || - shExpMatch(host, "google.co.jp") || - shExpMatch(host, "*.google.co.jp") || - shExpMatch(host, "googleapis.com") || - shExpMatch(host, "*.googleapis.com") || - shExpMatch(host, "googleusercontent.com") || - shExpMatch(host, "*.googleusercontent.com") || - shExpMatch(host, "googletagmanager.com") || - shExpMatch(host, "*.googletagmanager.com") || - shExpMatch(host, "google-analytics.com") || - shExpMatch(host, "*.google-analytics.com") || - shExpMatch(host, "gstatic.com") || - shExpMatch(host, "*.gstatic.com") || - shExpMatch(host, "ggpht.com") || - shExpMatch(host, "*.ggpht.com") || - shExpMatch(host, "googlehosted.com") || - shExpMatch(host, "*.googlehosted.com") || - shExpMatch(host, "1e100.net") || - shExpMatch(host, "*.1e100.net") || - shExpMatch(host, "youtube.com") || - shExpMatch(host, "*.youtube.com") || - shExpMatch(host, "ytimg.com") || - shExpMatch(host, "*.ytimg.com") || - shExpMatch(host, "googlevideo.com") || - shExpMatch(host, "*.googlevideo.com") || - shExpMatch(host, "gemini.google.com") || - shExpMatch(host, "*.gemini.google.com") || - shExpMatch(host, "generativeai.googleapis.com") || - shExpMatch(host, "*.generativeai.googleapis.com") || - shExpMatch(host, "aistudio.google.com") || - shExpMatch(host, "*.aistudio.google.com") || - shExpMatch(host, "deepmind.google.com") || - shExpMatch(host, "*.deepmind.google.com") || - shExpMatch(host, "deepmind.com") || - shExpMatch(host, "*.deepmind.com")) { - return proxy; - } - - // OpenAI / ChatGPT 全系 + CDN - if (shExpMatch(host, "openai.com") || - shExpMatch(host, "*.openai.com") || - shExpMatch(host, "chatgpt.com") || - shExpMatch(host, "*.chatgpt.com") || - shExpMatch(host, "oaistatic.com") || - shExpMatch(host, "*.oaistatic.com") || - shExpMatch(host, "oaiusercontent.com") || - shExpMatch(host, "*.oaiusercontent.com") || - shExpMatch(host, "openaiusercontent.com") || - shExpMatch(host, "*.openaiusercontent.com")) { - return proxy; - } - - // Grok / xAI 全系 + CDN - if (shExpMatch(host, "grok.com") || - shExpMatch(host, "*.grok.com") || - shExpMatch(host, "x.com") || - shExpMatch(host, "*.x.com") || - shExpMatch(host, "twitter.com") || - shExpMatch(host, "*.twitter.com") || - shExpMatch(host, "twimg.com") || - shExpMatch(host, "*.twimg.com") || - shExpMatch(host, "x.ai") || - shExpMatch(host, "*.x.ai") || - shExpMatch(host, "xai.com") || - shExpMatch(host, "*.xai.com")) { - return proxy; - } - - // Meta 全系 + CDN - if (shExpMatch(host, "meta.com") || - shExpMatch(host, "*.meta.com") || - shExpMatch(host, "meta.ai") || - shExpMatch(host, "*.meta.ai") || - shExpMatch(host, "facebook.com") || - shExpMatch(host, "*.facebook.com") || - shExpMatch(host, "fb.com") || - shExpMatch(host, "*.fb.com") || - shExpMatch(host, "fbcdn.net") || - shExpMatch(host, "*.fbcdn.net") || - shExpMatch(host, "fbsbx.com") || - shExpMatch(host, "*.fbsbx.com") || - shExpMatch(host, "instagram.com") || - shExpMatch(host, "*.instagram.com") || - shExpMatch(host, "cdninstagram.com") || - shExpMatch(host, "*.cdninstagram.com") || - shExpMatch(host, "threads.net") || - shExpMatch(host, "*.threads.net") || - shExpMatch(host, "whatsapp.com") || - shExpMatch(host, "*.whatsapp.com") || - shExpMatch(host, "whatsapp.net") || - shExpMatch(host, "*.whatsapp.net") || - shExpMatch(host, "messenger.com") || - shExpMatch(host, "*.messenger.com")) { - return proxy; - } - - // Claude / Anthropic 全系 + CDN - if (shExpMatch(host, "anthropic.com") || - shExpMatch(host, "*.anthropic.com") || - shExpMatch(host, "claude.ai") || - shExpMatch(host, "*.claude.ai") || - shExpMatch(host, "claudeusercontent.com") || - shExpMatch(host, "*.claudeusercontent.com")) { - return proxy; - } - - // Cloudflare 全系 - if (shExpMatch(host, "cloudflare.com") || - shExpMatch(host, "*.cloudflare.com") || - shExpMatch(host, "cloudflare.net") || - shExpMatch(host, "*.cloudflare.net") || - shExpMatch(host, "cloudflareinsights.com") || - shExpMatch(host, "*.cloudflareinsights.com") || - shExpMatch(host, "cloudflarestatus.com") || - shExpMatch(host, "*.cloudflarestatus.com") || - shExpMatch(host, "cloudflarecdn.com") || - shExpMatch(host, "*.cloudflarecdn.com") || - shExpMatch(host, "cf-cdn.com") || - shExpMatch(host, "*.cf-cdn.com") || - shExpMatch(host, "workers.dev") || - shExpMatch(host, "*.workers.dev") || - shExpMatch(host, "pages.dev") || - shExpMatch(host, "*.pages.dev")) { - return proxy; - } - - // GitHub 全系 - if (shExpMatch(host, "github.com") || - shExpMatch(host, "*.github.com") || - shExpMatch(host, "githubusercontent.com") || - shExpMatch(host, "*.githubusercontent.com") || - shExpMatch(host, "githubassets.com") || - shExpMatch(host, "*.githubassets.com") || - shExpMatch(host, "github.io") || - shExpMatch(host, "*.github.io") || - shExpMatch(host, "githubcopilot.com") || - shExpMatch(host, "*.githubcopilot.com") || - shExpMatch(host, "ghcr.io") || - shExpMatch(host, "*.ghcr.io")) { - return proxy; - } - - // Hugging Face 全系 + CDN - if (shExpMatch(host, "huggingface.co") || - shExpMatch(host, "*.huggingface.co") || - shExpMatch(host, "hf.co") || - shExpMatch(host, "*.hf.co") || - shExpMatch(host, "huggingfacecdn.com") || - shExpMatch(host, "*.huggingfacecdn.com")) { - return proxy; - } - - // Microsoft Copilot 全系 + Bing + Azure CDN - if (shExpMatch(host, "microsoft.com") || - shExpMatch(host, "*.microsoft.com") || - shExpMatch(host, "bing.com") || - shExpMatch(host, "*.bing.com") || - shExpMatch(host, "bingapis.com") || - shExpMatch(host, "*.bingapis.com") || - shExpMatch(host, "bing.net") || - shExpMatch(host, "*.bing.net") || - shExpMatch(host, "live.com") || - shExpMatch(host, "*.live.com") || - shExpMatch(host, "microsoftonline.com") || - shExpMatch(host, "*.microsoftonline.com") || - shExpMatch(host, "microsoft365.com") || - shExpMatch(host, "*.microsoft365.com") || - shExpMatch(host, "office.com") || - shExpMatch(host, "*.office.com") || - shExpMatch(host, "office.net") || - shExpMatch(host, "*.office.net") || - shExpMatch(host, "azure.com") || - shExpMatch(host, "*.azure.com") || - shExpMatch(host, "azureedge.net") || - shExpMatch(host, "*.azureedge.net") || - shExpMatch(host, "azurefd.net") || - shExpMatch(host, "*.azurefd.net") || - shExpMatch(host, "msn.com") || - shExpMatch(host, "*.msn.com") || - shExpMatch(host, "msecnd.net") || - shExpMatch(host, "*.msecnd.net") || - shExpMatch(host, "msftncsi.com") || - shExpMatch(host, "*.msftncsi.com") || - shExpMatch(host, "s-microsoft.com") || - shExpMatch(host, "*.s-microsoft.com")) { - return proxy; - } - - // Steam 全系 + CDN / 社区 / 下载 - if (shExpMatch(host, "steampowered.com") || - shExpMatch(host, "*.steampowered.com") || - shExpMatch(host, "steamcommunity.com") || - shExpMatch(host, "*.steamcommunity.com") || - shExpMatch(host, "steam.com") || - shExpMatch(host, "*.steam.com") || - shExpMatch(host, "steamgames.com") || - shExpMatch(host, "*.steamgames.com") || - shExpMatch(host, "steamusercontent.com") || - shExpMatch(host, "*.steamusercontent.com") || - shExpMatch(host, "steamcontent.com") || - shExpMatch(host, "*.steamcontent.com") || - shExpMatch(host, "steamstatic.com") || - shExpMatch(host, "*.steamstatic.com") || - shExpMatch(host, "steam-chat.com") || - shExpMatch(host, "*.steam-chat.com") || - shExpMatch(host, "steam-api.com") || - shExpMatch(host, "*.steam-api.com") || - shExpMatch(host, "steamserver.net") || - shExpMatch(host, "*.steamserver.net") || - shExpMatch(host, "steamcdn-a.akamaihd.net") || - shExpMatch(host, "*.steamcdn-a.akamaihd.net") || - shExpMatch(host, "steamcommunity-a.akamaihd.net") || - shExpMatch(host, "*.steamcommunity-a.akamaihd.net") || - shExpMatch(host, "steamstore-a.akamaihd.net") || - shExpMatch(host, "*.steamstore-a.akamaihd.net") || - shExpMatch(host, "steamuserimages-a.akamaihd.net") || - shExpMatch(host, "*.steamuserimages-a.akamaihd.net") || - shExpMatch(host, "akamaihd.net") || - shExpMatch(host, "*.akamaihd.net") || - shExpMatch(host, "valvesoftware.com") || - shExpMatch(host, "*.valvesoftware.com")) { - return proxy; - } - - // Discord 全系 - if (shExpMatch(host, "discord.com") || - shExpMatch(host, "*.discord.com") || - shExpMatch(host, "discordapp.com") || - shExpMatch(host, "*.discordapp.com") || - shExpMatch(host, "discord.gg") || - shExpMatch(host, "*.discord.gg") || - shExpMatch(host, "discord.media") || - shExpMatch(host, "*.discord.media") || - shExpMatch(host, "discordapp.net") || - shExpMatch(host, "*.discordapp.net")) { - return proxy; - } - - // Telegram 全系 - if (shExpMatch(host, "telegram.org") || - shExpMatch(host, "*.telegram.org") || - shExpMatch(host, "t.me") || - shExpMatch(host, "*.t.me") || - shExpMatch(host, "telega.one") || - shExpMatch(host, "*.telega.one") || - shExpMatch(host, "telegram-cdn.org") || - shExpMatch(host, "*.telegram-cdn.org")) { - return proxy; - } - - // Reddit 全系 - if (shExpMatch(host, "reddit.com") || - shExpMatch(host, "*.reddit.com") || - shExpMatch(host, "redd.it") || - shExpMatch(host, "*.redd.it") || - shExpMatch(host, "redditmedia.com") || - shExpMatch(host, "*.redditmedia.com") || - shExpMatch(host, "redditstatic.com") || - shExpMatch(host, "*.redditstatic.com")) { - return proxy; - } - - // LINE 全系 - if (shExpMatch(host, "line.me") || - shExpMatch(host, "*.line.me") || - shExpMatch(host, "line-apps.com") || - shExpMatch(host, "*.line-apps.com") || - shExpMatch(host, "line-scdn.net") || - shExpMatch(host, "*.line-scdn.net")) { - return proxy; - } - - // Spotify 全系 - if (shExpMatch(host, "spotify.com") || - shExpMatch(host, "*.spotify.com") || - shExpMatch(host, "spotifycdn.com") || - shExpMatch(host, "*.spotifycdn.com") || - shExpMatch(host, "scdn.co") || - shExpMatch(host, "*.scdn.co")) { - return proxy; - } - - // Netflix 全系 - if (shExpMatch(host, "netflix.com") || - shExpMatch(host, "*.netflix.com") || - shExpMatch(host, "nflxvideo.net") || - shExpMatch(host, "*.nflxvideo.net") || - shExpMatch(host, "nflximg.net") || - shExpMatch(host, "*.nflximg.net") || - shExpMatch(host, "nflxext.com") || - shExpMatch(host, "*.nflxext.com")) { - return proxy; - } - - // Disney+ 全系 - if (shExpMatch(host, "disneyplus.com") || - shExpMatch(host, "*.disneyplus.com") || - shExpMatch(host, "disney-plus.net") || - shExpMatch(host, "*.disney-plus.net") || - shExpMatch(host, "dssott.com") || - shExpMatch(host, "*.dssott.com")) { - return proxy; - } - - // TikTok 全系 - if (shExpMatch(host, "tiktok.com") || - shExpMatch(host, "*.tiktok.com") || - shExpMatch(host, "tiktokcdn.com") || - shExpMatch(host, "*.tiktokcdn.com") || - shExpMatch(host, "tiktokv.com") || - shExpMatch(host, "*.tiktokv.com")) { - return proxy; - } - - // PlayStation 全系 - if (shExpMatch(host, "playstation.com") || - shExpMatch(host, "*.playstation.com") || - shExpMatch(host, "sonyentertainmentnetwork.com") || - shExpMatch(host, "*.sonyentertainmentnetwork.com")) { - return proxy; - } - - // Nintendo 全系 - if (shExpMatch(host, "nintendo.com") || - shExpMatch(host, "*.nintendo.com") || - shExpMatch(host, "nintendowifi.net") || - shExpMatch(host, "*.nintendowifi.net")) { - return proxy; - } - - // Epic Games 全系 - if (shExpMatch(host, "epicgames.com") || - shExpMatch(host, "*.epicgames.com") || - shExpMatch(host, "unrealengine.com") || - shExpMatch(host, "*.unrealengine.com")) { - return proxy; - } - - // DuckDuckGo / Brave Search - if (shExpMatch(host, "duckduckgo.com") || - shExpMatch(host, "*.duckduckgo.com") || - shExpMatch(host, "search.brave.com") || - shExpMatch(host, "*.search.brave.com")) { - return proxy; - } - - // Proton 全系 - if (shExpMatch(host, "proton.me") || - shExpMatch(host, "*.proton.me") || - shExpMatch(host, "protonmail.com") || - shExpMatch(host, "*.protonmail.com") || - shExpMatch(host, "protonvpn.com") || - shExpMatch(host, "*.protonvpn.com")) { - return proxy; - } - - // Stack Overflow / Stack Exchange 全系 - if (shExpMatch(host, "stackoverflow.com") || - shExpMatch(host, "*.stackoverflow.com") || - shExpMatch(host, "stackexchange.com") || - shExpMatch(host, "*.stackexchange.com") || - shExpMatch(host, "superuser.com") || - shExpMatch(host, "*.superuser.com") || - shExpMatch(host, "serverfault.com") || - shExpMatch(host, "*.serverfault.com")) { - return proxy; - } - - // Quora / Medium - if (shExpMatch(host, "quora.com") || - shExpMatch(host, "*.quora.com") || - shExpMatch(host, "medium.com") || - shExpMatch(host, "*.medium.com")) { - return proxy; - } - - // Pixiv 全系 - if (shExpMatch(host, "pixiv.net") || - shExpMatch(host, "*.pixiv.net") || - shExpMatch(host, "pximg.net") || - shExpMatch(host, "*.pximg.net")) { - return proxy; - } - - // ニコニコ 全系 - if (shExpMatch(host, "nicovideo.jp") || - shExpMatch(host, "*.nicovideo.jp") || - shExpMatch(host, "nico.ms") || - shExpMatch(host, "*.nico.ms")) { - return proxy; - } - - // EA / Origin / Apex 全系 - if (shExpMatch(host, "ea.com") || - shExpMatch(host, "*.ea.com") || - shExpMatch(host, "origin.com") || - shExpMatch(host, "*.origin.com") || - shExpMatch(host, "eaassets.com") || - shExpMatch(host, "*.eaassets.com") || - shExpMatch(host, "apexlegends.com") || - shExpMatch(host, "*.apexlegends.com")) { - return proxy; - } - - // Battle.net / Blizzard 全系 - if (shExpMatch(host, "battle.net") || - shExpMatch(host, "*.battle.net") || - shExpMatch(host, "blizzard.com") || - shExpMatch(host, "*.blizzard.com")) { - return proxy; - } - - // Ubisoft 全系 - if (shExpMatch(host, "ubisoft.com") || - shExpMatch(host, "*.ubisoft.com") || - shExpMatch(host, "ubi.com") || - shExpMatch(host, "*.ubi.com")) { - return proxy; - } - - // Riot Games / 英雄联盟 / Valorant 全系 - if (shExpMatch(host, "riotgames.com") || - shExpMatch(host, "*.riotgames.com") || - shExpMatch(host, "leagueoflegends.com") || - shExpMatch(host, "*.leagueoflegends.com") || - shExpMatch(host, "valorant.com") || - shExpMatch(host, "*.valorant.com")) { - return proxy; - } - - // GOG / itch.io 全系 - if (shExpMatch(host, "gog.com") || - shExpMatch(host, "*.gog.com") || - shExpMatch(host, "itch.io") || - shExpMatch(host, "*.itch.io")) { - return proxy; - } - - // Twitch 全系 - if (shExpMatch(host, "twitch.tv") || - shExpMatch(host, "*.twitch.tv") || - shExpMatch(host, "ttvnw.net") || - shExpMatch(host, "*.ttvnw.net") || - shExpMatch(host, "twitchcdn.net") || - shExpMatch(host, "*.twitchcdn.net")) { - return proxy; - } - - // Crunchyroll 全系 - if (shExpMatch(host, "crunchyroll.com") || - shExpMatch(host, "*.crunchyroll.com")) { - return proxy; - } - - // Amazon Prime Video 全系 - if (shExpMatch(host, "primevideo.com") || - shExpMatch(host, "*.primevideo.com")) { - return proxy; - } - - // Apple TV+ 全系 - if (shExpMatch(host, "tv.apple.com") || - shExpMatch(host, "tvplus.apple.com")) { - return proxy; - } - - // HBO Max / Hulu / Paramount+ 全系 - if (shExpMatch(host, "max.com") || - shExpMatch(host, "*.max.com") || - shExpMatch(host, "hulu.com") || - shExpMatch(host, "*.hulu.com") || - shExpMatch(host, "paramountplus.com") || - shExpMatch(host, "*.paramountplus.com")) { - return proxy; - } - - // Slack 全系 - if (shExpMatch(host, "slack.com") || - shExpMatch(host, "*.slack.com") || - shExpMatch(host, "slack-edge.com") || - shExpMatch(host, "*.slack-edge.com") || - shExpMatch(host, "slack-imgs.com") || - shExpMatch(host, "*.slack-imgs.com")) { - return proxy; - } - - // Zoom 全系 - if (shExpMatch(host, "zoom.us") || - shExpMatch(host, "*.zoom.us") || - shExpMatch(host, "zoom.com") || - shExpMatch(host, "*.zoom.com") || - shExpMatch(host, "zoomgov.com") || - shExpMatch(host, "*.zoomgov.com")) { - return proxy; - } - - // Signal 全系 - if (shExpMatch(host, "signal.org") || - shExpMatch(host, "*.signal.org")) { - return proxy; - } - - // Docker 全系 - if (shExpMatch(host, "docker.com") || - shExpMatch(host, "*.docker.com") || - shExpMatch(host, "docker.io") || - shExpMatch(host, "*.docker.io")) { - return proxy; - } - - // GitLab 全系 - if (shExpMatch(host, "gitlab.com") || - shExpMatch(host, "*.gitlab.com") || - shExpMatch(host, "gitlab.io") || - shExpMatch(host, "*.gitlab.io")) { - return proxy; - } - - // Vercel 全系 - if (shExpMatch(host, "vercel.com") || - shExpMatch(host, "*.vercel.com") || - shExpMatch(host, "vercel.app") || - shExpMatch(host, "*.vercel.app")) { - return proxy; - } - - // Netlify 全系 - if (shExpMatch(host, "netlify.com") || - shExpMatch(host, "*.netlify.com") || - shExpMatch(host, "netlify.app") || - shExpMatch(host, "*.netlify.app")) { - return proxy; - } - - // Perplexity / Poe / Midjourney 全系 - if (shExpMatch(host, "perplexity.ai") || - shExpMatch(host, "*.perplexity.ai") || - shExpMatch(host, "poe.com") || - shExpMatch(host, "*.poe.com") || - shExpMatch(host, "midjourney.com") || - shExpMatch(host, "*.midjourney.com")) { - return proxy; - } - - // Figma / Canva 全系 - if (shExpMatch(host, "figma.com") || - shExpMatch(host, "*.figma.com") || - shExpMatch(host, "canva.com") || - shExpMatch(host, "*.canva.com")) { - return proxy; - } - - // LinkedIn 全系 - if (shExpMatch(host, "linkedin.com") || - shExpMatch(host, "*.linkedin.com") || - shExpMatch(host, "licdn.com") || - shExpMatch(host, "*.licdn.com")) { - return proxy; - } - - // Pinterest 全系 - if (shExpMatch(host, "pinterest.com") || - shExpMatch(host, "*.pinterest.com") || - shExpMatch(host, "pinimg.com") || - shExpMatch(host, "*.pinimg.com")) { - return proxy; - } - - // Tumblr 全系 - if (shExpMatch(host, "tumblr.com") || - shExpMatch(host, "*.tumblr.com")) { - return proxy; - } - - // Snapchat 全系 - if (shExpMatch(host, "snapchat.com") || - shExpMatch(host, "*.snapchat.com") || - shExpMatch(host, "sc-cdn.net") || - shExpMatch(host, "*.sc-cdn.net")) { - return proxy; - } - - // Wikipedia / Wikimedia 全系 - if (shExpMatch(host, "wikipedia.org") || - shExpMatch(host, "*.wikipedia.org") || - shExpMatch(host, "wikimedia.org") || - shExpMatch(host, "*.wikimedia.org")) { - return proxy; - } - - // PayPal 全系 - if (shExpMatch(host, "paypal.com") || - shExpMatch(host, "*.paypal.com") || - shExpMatch(host, "paypalobjects.com") || - shExpMatch(host, "*.paypalobjects.com")) { - return proxy; - } - - // Patreon 全系 - if (shExpMatch(host, "patreon.com") || - shExpMatch(host, "*.patreon.com")) { - return proxy; - } - - // 通用 CDN - if (shExpMatch(host, "cloudfront.net") || - shExpMatch(host, "*.cloudfront.net") || - shExpMatch(host, "fastly.net") || - shExpMatch(host, "*.fastly.net") || - shExpMatch(host, "fastlylb.net") || - shExpMatch(host, "*.fastlylb.net") || - shExpMatch(host, "jsdelivr.net") || - shExpMatch(host, "*.jsdelivr.net") || - shExpMatch(host, "unpkg.com") || - shExpMatch(host, "*.unpkg.com") || - shExpMatch(host, "statically.io") || - shExpMatch(host, "*.statically.io") || - shExpMatch(host, "cloudinary.com") || - shExpMatch(host, "*.cloudinary.com") || - shExpMatch(host, "imgur.com") || - shExpMatch(host, "*.imgur.com")) { - return proxy; - } - - // linux.do - if (shExpMatch(host, "linux.do") || - shExpMatch(host, "*.linux.do")) { - return proxy; - } - - return "DIRECT"; -} -''' - -def _build_pac(proxy_spec: str) -> str: - """使用内置完整 PAC,并替换为当前模式对应的代理地址。""" - return _BUILT_IN_PAC_DIRECT.replace( - 'var proxy = "PROXY 127.0.0.1:1080";', - f'var proxy = "PROXY {proxy_spec}";' - ) -def _start_pac_server(pac_content: str) -> int: - """在 127.0.0.1 启动内存 PAC HTTP 服务,返回实际监听的端口。""" - global _pac_server - _stop_pac_server() - import http.server - import socketserver - - class _PACHandler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path in ("/proxy.pac", "/proxy_wg.pac"): - body = pac_content.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/x-ns-proxy-autoconfig") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format, *args): - pass # 避免在日志区输出大量 HTTP 访问日志 - - class _ReuseAddrTCPServer(socketserver.TCPServer): - allow_reuse_address = True - - srv = _ReuseAddrTCPServer(("127.0.0.1", 0), _PACHandler) - port = srv.server_address[1] - threading.Thread(target=srv.serve_forever, daemon=True).start() - _pac_server = {"server": srv, "port": port} - return port - -def _stop_pac_server(): - """停止本地 PAC HTTP 服务;shutdown() 可能阻塞,因此先关闭 socket 再异步收尾。""" - global _pac_server - if _pac_server: - srv = _pac_server["server"] - _pac_server = None - try: - # 先关闭 socket,让 serve_forever() 从 select/accept 中立即退出 - srv.socket.close() - except Exception: - pass - # 在后台线程里尝试优雅 shutdown,最多等 1 秒,避免阻塞 GUI - def _shutdown(): - try: - srv.shutdown() - except Exception: - pass - try: - srv.server_close() - except Exception: - pass - t = threading.Thread(target=_shutdown, daemon=True) - t.start() - t.join(timeout=1.0) - -# ── 日志输出 ────────────────────────────────────────── -# GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit -_log_func = None - -def log(msg: str): - """输出一行日志到 GUI 日志区。""" - if _log_func: - _log_func(msg) - -def log_red(msg: str): - """输出一行红色警告日志到 GUI 日志区。""" - if _log_func: - _log_func(f'{msg}') - -# ── 通用工具函数 ────────────────────────────────────── - -def _run(cmd, warn=True): - """无窗口执行子进程,失败时记录警告日志。""" - r = subprocess.run(cmd, capture_output=True, text=True, - creationflags=subprocess.CREATE_NO_WINDOW) - if warn and r.returncode != 0: - err = r.stderr.strip() if r.stderr else "" - log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}") - return r - -def is_admin(): - """判断当前进程是否以管理员身份运行。""" - try: - return ctypes.windll.shell32.IsUserAnAdmin() - except: - return False - -def elevate(): - """非管理员时请求 UAC 提权并重新启动自身。""" - script = str(Path(__file__).resolve()) - cwd = str(Path(__file__).resolve().parent) - # SW_SHOWNORMAL(1):GUI 版本必须显示窗口;编译为 EXE 后由 uac_admin 清单处理 - ctypes.windll.shell32.ShellExecuteW( - None, "runas", sys.executable, f'"{script}"', cwd, 1 - ) - -def _wg_active(max_age=150): - """判断 jp_client 接口是否处于活跃状态。 - - 使用 wg show 的机器可读输出 latest-handshakes(Unix 时间戳), - 不再解析人类可读文本——不同 wg 版本的文案不一致 - (例如刚握手时有的版本输出 "now"),文本解析会误报"握手过旧"。 - """ - r = _run([str(WG_CLI), "show", "jp_client", "latest-handshakes"], warn=False) - out = (r.stdout or "").strip() - if not out: - return False - try: - # 每行格式: \t<最近握手的 Unix 时间戳>,0 表示从未握手 - latest = max(int(line.split()[-1]) for line in out.splitlines() if line.strip()) - except (ValueError, IndexError): - return False - if latest == 0: - return False - return (time.time() - latest) <= max_age - -def _wg_show(): - """返回 jp_client 接口完整状态文本,用于故障诊断。""" - r = _run([str(WG_CLI), "show", "jp_client"], warn=False) - return r.stdout or "" - -def _cleanup_legacy_wg_service(): - """清理旧版 WireGuard 服务名(jp_wg_client.conf 时期遗留)。""" - legacy = "WireGuardTunnel$jp_wg_client" - _run(["sc", "stop", legacy], warn=False) - _run(["sc", "delete", legacy], warn=False) - - -def _service_installed(name): - """通过 sc query 判断服务是否已安装。""" - r = _run(["sc", "query", name], warn=False) - return r.returncode == 0 and "SERVICE_NAME" in (r.stdout or "") - -def _service_running(name): - """通过 sc query 判断服务是否正在运行。""" - r = _run(["sc", "query", name], warn=False) - return r.returncode == 0 and "RUNNING" in (r.stdout or "") - -def _wait_service_state(name, running=True, timeout=15): - """轮询等待服务进入指定状态。""" - for _ in range(timeout): - if _service_running(name) == running: - return True - time.sleep(1) - return False - -def _stop_service(name): - """停止服务并等待其进入停止状态。""" - if not _service_running(name): - return True - _run(["sc", "stop", name], warn=False) - return _wait_service_state(name, running=False, timeout=15) - -def _delete_service(name): - """删除服务并等待其从 sc query 中消失。""" - _run(["sc", "delete", name], warn=False) - for _ in range(10): - if not _service_installed(name): - return True - time.sleep(1) - return False - -def _start_service(name): - """启动服务;1056 表示服务已在运行,也视为成功。""" - r = _run(["sc", "start", name], warn=False) - if r.returncode in (0, 1056): - return True - return False - -def _ensure_persist_wg_files(): - """确保 EXE 目录下存在 wireguard.exe 和 jp_client.conf,供安装服务使用。 - - 打包后 wg/ 和配置在 _MEIPASS 临时目录(BASE_DIR),会被系统清理, - 服务安装必须用持久路径,因此统一从 BASE_DIR 拷贝到 EXE 所在目录。 - """ - try: - import shutil - if not _PERSIST_WG_EXE.exists(): - persist_wg_dir = PERSIST_DIR / "wg" - persist_wg_dir.mkdir(parents=True, exist_ok=True) - for name in ("wireguard.exe", "wg.exe"): - dst = persist_wg_dir / name - if not dst.exists(): - shutil.copy2(str(WG_DIR / name), str(dst)) - log(f" 已拷贝 wg/ 到 {persist_wg_dir}") - if not _PERSIST_WG_CONF.exists(): - shutil.copy2(str(WG_CONF), str(_PERSIST_WG_CONF)) - log(f" 已拷贝配置到 {_PERSIST_WG_CONF}") - return True - except Exception as e: - log(f" 准备服务文件失败: {e}") - return False - -def _install_wg_service(): - """停止并删除旧服务后,用 PERSIST_DIR 中的最新配置重新安装。""" - log(" 安装 WireGuard 隧道服务...") - _cleanup_legacy_wg_service() - if not _ensure_persist_wg_files(): - return False - if _service_installed(WG_SERVICE): - _stop_service(WG_SERVICE) - _delete_service(WG_SERVICE) - ir = _run([str(_PERSIST_WG_EXE), "/installtunnelservice", str(_PERSIST_WG_CONF)]) - log(f" installtunnelservice -> 返回码 {ir.returncode}" + (f" (错误: {ir.stderr.strip()})" if ir.returncode != 0 and ir.stderr.strip() else "")) - if ir.returncode == 0: - # installtunnelservice 注册的服务默认是自动启动,会导致开机关机自连、 - # 程序一打开就显示"已连接"。改为手动启动,只在用户开启代理时拉起隧道。 - _run(["sc", "config", WG_SERVICE, "start=", "demand"], warn=False) - return ir.returncode == 0 - -def _wait_handshake(max_attempts=15, interval=2): - """等待 WireGuard 握手成功。""" - for attempt in range(max_attempts): - time.sleep(interval) - if _wg_active(): - log(f" 握手成功 (第{attempt + 1}次检查)") - return True - return False - - -def wg_on(): - """启动 WireGuard 隧道并等待握手成功。""" - if _wg_active(): - log("WireGuard 隧道已在线") - return True - - # 预检:解析端点域名 - ep = _endpoint_domain() - log(f"预检: 解析端点 {ep} ...") - try: - import socket - wg_port = _endpoint_port() - addr = socket.getaddrinfo(ep, wg_port, socket.AF_INET, socket.SOCK_DGRAM) - ip = addr[0][4][0] - log(f" DNS 解析 -> {ip}:{wg_port}") - except Exception as e: - log(f" DNS 解析失败: {e}") - return False - - log(f"正在启动 WireGuard 服务: {WG_SERVICE} ...") - - # 服务未安装则安装;已安装但未运行则尝试启动 - if not _service_installed(WG_SERVICE): - if not _install_wg_service(): - log("WireGuard 隧道未连通") - return False - elif not _service_running(WG_SERVICE): - if not _start_service(WG_SERVICE): - log(" 服务启动失败,尝试重新安装...") - if not _install_wg_service(): - log("WireGuard 隧道未连通") - return False - - # 等待服务真正进入 RUNNING 状态 - if not _wait_service_state(WG_SERVICE, running=True, timeout=15): - log(" 服务未进入运行状态") - log("WireGuard 隧道未连通") - return False - - log(" 等待握手完成...") - if _wait_handshake(): - log("WireGuard 隧道已连接") - return True - - log(" 握手超时,重启服务重试...") - _stop_service(WG_SERVICE) - _delete_service(WG_SERVICE) - if not _install_wg_service(): - log("WireGuard 隧道未连通") - return False - if not _wait_service_state(WG_SERVICE, running=True, timeout=15): - log(" 服务重启后未进入运行状态") - log("WireGuard 隧道未连通") - return False - - log(" 再次等待握手...") - if _wait_handshake(): - log("WireGuard 隧道已连接") - return True - - log(" 握手仍未完成,当前接口状态:") - show_out = _wg_show() - for line in show_out.splitlines(): - log(f" {line.rstrip()}") - - # 本地接口已启动但有发送无接收,说明对端未响应 UDP 握手 - if "0 B received" in show_out: - wg_port = _endpoint_port() - tcp_ok = _tcp_endpoint_reachable(port=80) - if tcp_ok: - log(f" 诊断: TCP/HTTP 到服务器可达,但 WireGuard UDP {wg_port} 无响应。") - log(f" 常见原因:服务器端 WG 服务未运行、密钥不匹配、或 UDP {wg_port} 被防火墙拦截。") - else: - log(" 诊断: TCP/HTTP 到服务器也不通,可能是服务器整体不可达或本地网络受限。") - - log("WireGuard 隧道未连通") - return False - -def wg_off(): - """停止 WireGuard 服务(即使握手已过期也尝试停止)。""" - _run(["sc", "stop", WG_SERVICE], warn=False) - return True - -def proxy_on(pac_url=None): - """设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。""" - if pac_url: - # PAC 模式:仅设置 AutoConfigURL,ProxyEnable 置 0, - # 避免程序读到"固定代理已启用但服务器为空"的混乱状态 - _run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) - # PAC 模式:清理固定代理遗留项,避免两种设置冲突 - _run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False) - _run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False) - _run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t", "REG_SZ", "/d", pac_url, "/f"]) - log(f"系统代理已设置 (PAC)") - else: - hp = f"{PROXY_HOST}:{PROXY_PORT}" - _run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]) - _run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t", "REG_SZ", "/d", hp, "/f"]) - _run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", "", "/f"]) - log(f"系统代理已设置: {hp}") - -def proxy_off(): - """关闭系统代理并停止 TLS 转发器 / PAC 本地服务。""" - log("正在关闭系统代理...") - _run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) - # 清除 ProxyServer 和 AutoConfigURL,确保 PAC / 固定代理两种模式都能正确关闭 - _run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False) - _run(["reg", "delete", PROXY_REG, "/v", "AutoConfigURL", "/f"], warn=False) - log("系统代理已关闭") - _tls_stop() - _stop_pac_server() - -# ── 本地认证转发器(直连 / WG 模式均使用) ───────────────── - -# ── 流量统计 ────────────────────────────────────────── -# 所有代理流量都经过本地 127.0.0.1:1080 转发器(直连 / WG / PAC 均如此), -# 在转发器里计数即可覆盖全部模式。每次"开启代理"时清零,即"本次连接"流量。 -_traffic_lock = threading.Lock() -_traffic = {"up": 0, "down": 0} - -def _traffic_add(up=0, down=0): - with _traffic_lock: - _traffic["up"] += up - _traffic["down"] += down - -def _traffic_reset(): - with _traffic_lock: - _traffic["up"] = _traffic["down"] = 0 - -def _fmt_bytes(n): - f = float(n) - for unit in ("B", "KB", "MB", "GB", "TB"): - if f < 1024 or unit == "TB": - return f"{int(f)} {unit}" if unit == "B" else f"{f:.1f} {unit}" - f /= 1024 - -def _traffic_snapshot(): - with _traffic_lock: - return _traffic["up"], _traffic["down"] - -def _tls_start(remote_host, remote_port=443, use_tls=True): - """启动本地认证转发器:127.0.0.1:1080 -> remote_host:remote_port(可选 TLS 包裹)。""" - global _tls_forwarder - _tls_stop() - import socket, ssl, select - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - - svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - svr.bind(("127.0.0.1", _TLS_LOCAL_PORT)) - svr.listen(32) - svr.settimeout(1) - _tls_forwarder = {"sock": svr, "running": True} - - proto = "TLS" if use_tls else "TCP" - - def _run(): - while _tls_forwarder and _tls_forwarder.get("running"): - try: - client, addr = svr.accept() - threading.Thread(target=_tls_relay, args=(client, remote_host, remote_port, ctx, use_tls), daemon=True).start() - except socket.timeout: - continue - except Exception: - break - - t = threading.Thread(target=_run, daemon=True) - t.start() - log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})") - -_HTTP_METHODS = (b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ", - b"HEAD ", b"OPTIONS ", b"PATCH ") - -def _starts_with_method(data: bytes) -> bool: - return any(data.startswith(m) for m in _HTTP_METHODS) - -def _inject_proxy_auth(data: bytes) -> bytes: - """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。 - - nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization, - 因此转发器向上游发送 Basic 认证时使用 Authorization 头。 - """ - if not _starts_with_method(data): - return data - end = data.find(b"\r\n\r\n") - if end == -1: - return data - headers_lower = data[:end].lower() - if b"authorization:" in headers_lower: - return data - auth_line = f"Authorization: Basic {_PROXY_AUTH_BASIC}\r\n".encode() - return data[:end + 2] + auth_line + data[end + 2:] - -def _make_request_injector(): - """为明文 HTTP 代理连接生成逐条注入认证的流式处理器。 - - keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。 - 请求头跨包时先缓冲,凑齐 \r\n\r\n(或超过 64KB)后统一处理; - 请求体数据不以方法开头,原样透传。 - 注意:仅用于非 CONNECT 连接;CONNECT 隧道的后续流量是加密的,不能触碰。 - """ - pending = b"" - def feed(data: bytes) -> bytes: - nonlocal pending - if pending: - data, pending = pending + data, b"" - if not _starts_with_method(data): - return data - if b"\r\n\r\n" in data or len(data) >= 65536: - return _inject_proxy_auth(data) - pending = data - return b"" - return feed - -def _tls_relay(client, remote_host, remote_port, ctx, use_tls): - """处理一条客户端连接:注入代理认证后,与远端双向转发。""" - import socket, select - try: - remote = socket.create_connection((remote_host, remote_port), 10) - if use_tls: - remote = ctx.wrap_socket(remote, server_hostname=remote_host) - except Exception as e: - log(f"[转发器] 连接上游失败: {e}") - client.close() - return - - # 读取首包,判定连接类型:CONNECT 隧道 / 明文 HTTP / 其他 - client.settimeout(2.0) - is_connect = False - injector = None - try: - buf = b"" - is_http = None - max_headers = 65536 - while len(buf) < max_headers: - try: - chunk = client.recv(4096) - except socket.timeout: - break - if not chunk: - break - buf += chunk - if is_http is None: - first_crlf = buf.find(b"\r\n") - if first_crlf != -1: - first_line = buf[:first_crlf] - is_http = _starts_with_method(first_line + b" ") - if not is_http: - break - if b"\r\n\r\n" in buf: - break - - if is_http and b"\r\n\r\n" in buf: - is_connect = buf.startswith(b"CONNECT ") - if not is_connect: - # 明文 HTTP 代理:keep-alive 后续请求也要注入认证 - injector = _make_request_injector() - buf = _inject_proxy_auth(buf) - if buf: - remote.sendall(buf) - _traffic_add(up=len(buf)) - except Exception as e: - log(f"[转发器] 读取首包失败: {e}") - client.close() - remote.close() - return - - client.settimeout(None) - remote.settimeout(None) - # CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时; - # 明文 HTTP 连接空闲 120 秒回收 - idle_timeout = None if is_connect else 120 - try: - while True: - r, _, _ = select.select([client, remote], [], [], idle_timeout) - if not r: - break - if client in r: - data = client.recv(65536) - if not data: - break - if injector is not None: - data = injector(data) # 请求头未收全时会被缓冲,返回空 - if data: - remote.sendall(data) - _traffic_add(up=len(data)) - if remote in r: - data = remote.recv(65536) - if not data: - break - client.sendall(data) - _traffic_add(down=len(data)) - except Exception: - pass - finally: - try: - client.close() - except Exception: - pass - try: - remote.close() - except Exception: - pass - -def _tls_stop(): - """停止 TLS 转发器监听线程。""" - global _tls_forwarder - if _tls_forwarder: - _tls_forwarder["running"] = False - try: - _tls_forwarder["sock"].close() - except Exception: - pass - _tls_forwarder = None - -def is_proxy_on(): - """系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。""" - r = _run(["reg", "query", PROXY_REG, "/v", "ProxyEnable"], warn=False) - if "0x1" in (r.stdout or ""): - return True - r = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False) - return "127.0.0.1" in (r.stdout or "") - -def _cleanup_stale_proxy(): - """清理上次异常退出残留的系统代理设置。 - - TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向 - 127.0.0.1:1080 或本地 PAC 地址,说明是残留——不清理会导致全网不通。 - """ - try: - if not is_proxy_on(): - return - r = _run(["reg", "query", PROXY_REG, "/v", "ProxyServer"], warn=False) - r2 = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False) - stale = (f"{PROXY_HOST}:{PROXY_PORT}" in (r.stdout or "") - or "127.0.0.1" in (r2.stdout or "")) - if stale: - proxy_off() - except Exception: - pass - -# ── 图标 ────────────────────────────────────────────── - -# 程序图标 base64 编码(原 ico.ico),避免打包后依赖外部资源文件 -_ICO_B64 = "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" - -def load_icon(): - """加载程序图标,base64 解码失败时返回纯色兜底图标。""" - try: - import base64 - data = base64.b64decode(_ICO_B64) - pixmap = QPixmap() - pixmap.loadFromData(data) - if not pixmap.isNull(): - return QIcon(pixmap) - except Exception: - pass - # 兜底:生成一个纯色方形图标 - img = QImage(64, 64, QImage.Format_ARGB32) - img.fill(QColor(C_TITLE)) - return QIcon(QPixmap.fromImage(img)) - -# ── 状态指示灯(小圆点) ─────────────────────────────── - -class StatusDot(QLabel): - """用于 WireGuard / 系统代理状态的小圆点。""" - def __init__(self): - super().__init__() - self.setFixedSize(14, 14) - self.set_off() - - def set_on(self): - self.setStyleSheet(f""" - QLabel {{ - background-color: {C_GREEN}; - border-radius: 7px; - border: 1px solid rgba(81, 207, 102, 50); - }} - """) - - def set_off(self): - self.setStyleSheet(f""" - QLabel {{ - background-color: {C_RED}; - border-radius: 7px; - border: 1px solid rgba(255, 107, 107, 50); - }} - """) - - def set_disabled(self): - """未启用(直连模式下不检测 WireGuard)的中性灰色。""" - self.setStyleSheet(f""" - QLabel {{ - background-color: {C_BORDER}; - border-radius: 7px; - border: 1px solid rgba(136, 153, 170, 50); - }} - """) - -# ── 主窗口 ──────────────────────────────────────────── - -# ── 主窗口 ──────────────────────────────────────────── - -class MainWindow(QWidget): - log_signal = pyqtSignal(str) - _op_done = pyqtSignal(str, bool) - _update_done = pyqtSignal() - _update_download_done = pyqtSignal(bool, str) - _pac_tooltip_ready = pyqtSignal(str) - - _status_ready = pyqtSignal(object) - - def __init__(self): - super().__init__() - self._using_wg = False - self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的) - self._status_busy = False # 防止状态轮询线程堆叠 - # 把全局 log() 函数接到 GUI 日志区 - global _log_func - _log_func = self.log_signal.emit - self.log_signal.connect(self._append_log) - self._op_done.connect(self._after_op) - self._update_done.connect(self._on_update_done) - self._update_download_done.connect(self._on_update_download_done) - self._pac_tooltip_ready.connect(self._set_pac_tooltip) - self._status_ready.connect(self._apply_status) - - self.init_ui() - - # 每 2 秒刷新一次状态指示灯 - self.timer = QTimer() - self.timer.timeout.connect(self.refresh_status) - self.timer.start(2000) - self.refresh_status() - - # 启动时后台检查更新,完成前禁用开启按钮 - threading.Thread(target=self._check_update, daemon=True).start() - - def init_ui(self): - """初始化主界面布局与样式。""" - self.setWindowTitle("Royirin Rosmontis Private Proxy") - self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) - self.setWindowIcon(load_icon()) - self.setFixedSize(_BASE_WINDOW_WIDTH, _BASE_WINDOW_HEIGHT) - - self.setStyleSheet(f""" - QWidget {{ - background-color: {C_BG}; - color: {C_TEXT}; - font-family: "Microsoft YaHei", "Segoe UI", sans-serif; - font-size: 13px; - }} - """) - - layout = QVBoxLayout(self) - layout.setSpacing(14) - layout.setContentsMargins(24, 20, 24, 20) - - # 标题 - title = QLabel("Royirin Rosmontis Private Proxy") - title.setFont(QFont("Microsoft YaHei", 16, QFont.Bold)) - title.setStyleSheet(f"color: {C_TITLE};") - title.setAlignment(Qt.AlignCenter) - layout.addWidget(title) - - # 节点副标题 - subtitle = QLabel(f"节点: {_endpoint_domain()} (JP)") - subtitle.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;") - subtitle.setAlignment(Qt.AlignCenter) - layout.addWidget(subtitle) - - # 连接方式选择区 - mode_layout = QHBoxLayout() - mode_layout.setSpacing(8) - mode_layout.addStretch() - self.mode_group = QButtonGroup(self) - self.rb_direct = QRadioButton("直连") - self.rb_wg = QRadioButton("WG 隧道") - for rb, t in [(self.rb_direct, "直连"), (self.rb_wg, "WG 隧道")]: - rb.setStyleSheet(f""" - QRadioButton {{ - color: {C_SUBTEXT}; font-size: 12px; - }} - QRadioButton::checked {{ - color: {C_TEXT}; font-weight: bold; - }} - """) - self.mode_group.addButton(rb) - mode_layout.addWidget(rb) - self.rb_direct.setChecked(True) - self.mode_group.buttonClicked.connect(self._on_mode_change) - - self.cb_pac = QCheckBox("PAC 规则模式") - self.cb_pac.setChecked(False) # 默认关闭 PAC,由用户按需开启 - self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") - self.cb_pac.setToolTip("PAC 规则模式\n\n悬停此处可加载当前规则摘要...") - self.cb_pac.installEventFilter(self) - self._pac_tooltip_loaded = False - self._pac_tooltip_loading = False - mode_layout.addWidget(self.cb_pac) - mode_layout.addStretch() - layout.addLayout(mode_layout) - - # 分隔线 - line = QFrame() - line.setFrameShape(QFrame.HLine) - line.setStyleSheet(f"background-color: rgba(136, 153, 170, 80); max-height: 1px;") - layout.addWidget(line) - - # WireGuard 状态卡片 - wg_frame = QFrame() - wg_frame.setStyleSheet(f""" - QFrame {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 8px; - }} - """) - wg_layout = QHBoxLayout(wg_frame) - wg_layout.setContentsMargins(16, 12, 16, 12) - wg_label = QLabel("WireGuard 隧道") - wg_label.setStyleSheet(f"color: {C_TEXT}; font-weight: bold; background: transparent;") - self.wg_dot = StatusDot() - self.wg_status = QLabel("检测中...") - self.wg_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;") - wg_layout.addWidget(wg_label) - wg_layout.addStretch() - wg_layout.addWidget(self.wg_status) - wg_layout.addWidget(self.wg_dot) - layout.addWidget(wg_frame) - - # 系统代理状态卡片 - px_frame = QFrame() - px_frame.setStyleSheet(f""" - QFrame {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 8px; - }} - """) - px_layout = QHBoxLayout(px_frame) - px_layout.setContentsMargins(16, 12, 16, 12) - px_label = QLabel("IE 代理") - px_label.setStyleSheet(f"color: {C_TEXT}; font-weight: bold; background: transparent;") - self.px_dot = StatusDot() - self.px_status = QLabel("检测中...") - self.px_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;") - px_layout.addWidget(px_label) - px_layout.addStretch() - px_layout.addWidget(self.px_status) - px_layout.addWidget(self.px_dot) - layout.addWidget(px_frame) - - layout.addStretch() - - # 开启 / 关闭 代理按钮 - self.btn_toggle = QPushButton("开启代理") - self.btn_toggle.setMinimumHeight(40) - self._set_toggle_button_style(is_start=True) - self.btn_toggle.clicked.connect(self.on_toggle) - self.btn_toggle.setEnabled(False) # 更新检查完成前禁用,避免旧版本被使用 - layout.addWidget(self.btn_toggle) - - # 日志输出区 - self.log_text = QTextEdit() - self.log_text.setReadOnly(True) - self.log_text.setMaximumHeight(140) - self.log_text.setStyleSheet(f""" - QTextEdit {{ - background-color: {C_CARD}; - border: 1px solid {C_BORDER}; - border-radius: 6px; - color: {C_SUBTEXT}; - font-size: 11px; - padding: 4px 6px; - }} - QScrollBar:vertical {{ - width: 6px; - background: transparent; - }} - QScrollBar::handle:vertical {{ - background: {C_BORDER}; - border-radius: 3px; - min-height: 20px; - }} - """) - layout.addWidget(self.log_text) - - # 底部提示小字:独立容器收紧两行间距(主布局 spacing=14 太宽) - hints = QWidget() - hints_layout = QVBoxLayout(hints) - hints_layout.setContentsMargins(0, 0, 0, 0) - hints_layout.setSpacing(1) - - warn_label = QLabel("请勿二次分发此软件!") - warn_label.setStyleSheet(f"color: {C_RED}; font-size: 11px;") - warn_label.setAlignment(Qt.AlignCenter) - hints_layout.addWidget(warn_label) - - # 主背景是白色,纯白字不可见,用接近白的浅灰达到同样的弱化效果 - privacy_label = QLabel("使用此软件将会收集并上报您的IP地址及部分设备信息") - privacy_label.setStyleSheet("color: #c8ccd4; font-size: 9px;") - privacy_label.setAlignment(Qt.AlignCenter) - hints_layout.addWidget(privacy_label) - - layout.addWidget(hints) - - def closeEvent(self, event): - """关闭窗口时自动清理代理和隧道。""" - self.quit_app() - event.accept() - - # ── 开关操作 ── - def _set_toggle_button_style(self, is_start=True): - if is_start: - self.btn_toggle.setText("开启代理") - self.btn_toggle.setStyleSheet(f""" - QPushButton {{ - background-color: {C_GREEN}; color: #fff; border: none; - border-radius: 8px; padding: 10px 24px; - font-size: 14px; font-weight: bold; - }} - QPushButton:hover {{ background-color: #5ed978; }} - QPushButton:pressed {{ background-color: #45b85a; }} - QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} - """) - else: - self.btn_toggle.setText("关闭代理") - self.btn_toggle.setStyleSheet(f""" - QPushButton {{ - background-color: {C_RED}; color: #fff; border: none; - border-radius: 8px; padding: 10px 24px; - font-size: 14px; font-weight: bold; - }} - QPushButton:hover {{ background-color: #ff8787; }} - QPushButton:pressed {{ background-color: #e05555; }} - QPushButton:disabled {{ background-color: #3a1e1e; color: #666; }} - """) - - def on_toggle(self): - """根据按钮当前文字判断是开启还是关闭。""" - if self.btn_toggle.text() == "开启代理": - self.on_start() - else: - self.on_stop() - - def on_start(self): - """禁用开关按钮与模式/PAC 控件,在后台线程执行开启流程。""" - self.btn_toggle.setEnabled(False) - self.rb_direct.setEnabled(False) - self.rb_wg.setEnabled(False) - self.cb_pac.setEnabled(False) - # 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全) - use_wg = self.rb_wg.isChecked() - use_pac = self.cb_pac.isChecked() - threading.Thread(target=self._start_thread, args=(use_wg, use_pac), daemon=True).start() - - def _start_thread(self, use_wg, use_pac): - """后台开启代理:启动 WG/TLS,加载 PAC,设置系统代理。""" - try: - self._using_wg = use_wg - _traffic_reset() # 流量统计按"本次连接"计,开启时清零 - if use_wg: - if not wg_on(): - log("WireGuard 隧道未连通,未设置代理") - self._op_done.emit("start", False) - return - log("WG 隧道模式 — 代理走加密隧道") - _tls_start("10.10.0.1", 80, use_tls=False) - else: - log("TLS直连模式") - ep = _endpoint_domain() - log(f" 目标域名: {ep}") - _tls_start(ep, 443, use_tls=True) - if use_pac: - domain = _endpoint_domain() - if domain == "???": - log("无法获取服务器域名,未设置 PAC") - self._op_done.emit("start", False) - return - pac_name = "proxy_wg.pac" if use_wg else "proxy.pac" - proxy_spec = f"{PROXY_HOST}:{PROXY_PORT}" - # Python 自带 SSL 跳过证书校验;Windows 直接取 HTTPS PAC 会因证书不信任失败 - remote_pac_url = f"https://{domain}/{pac_name}" - try: - import urllib.request - req = urllib.request.Request(remote_pac_url) - req.add_header("Authorization", f"Basic {_PROXY_AUTH_BASIC}") - data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig") - log(" 已从服务器加载 PAC") - # 兼容旧版 WG PAC(可能仍指向 10.10.0.1:80),统一替换为本地转发器 - data = data.replace('"PROXY 10.10.0.1:80"', f'"PROXY {proxy_spec}"') - except Exception as e: - log(f" 服务器 PAC 加载失败,使用内置规则: {e}") - data = _build_pac(proxy_spec) - # 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件 - pac_port = _start_pac_server(data) - pac_url = f"http://127.0.0.1:{pac_port}/{pac_name}" - log(f"使用 PAC: {pac_url}") - proxy_on(pac_url=pac_url) - else: - proxy_on() - self._proxy_set_by_us = True # 标记系统代理由本程序设置,退出时据此清理 - log("验证代理连通性...") - import urllib.request - if use_pac: - # PAC 模式下只验证代理服务器本身可达;具体域名路由交给 PAC 判断 - test_urls = [("google.com", "http://www.google.com")] - else: - test_urls = [("baidu.com", "http://www.baidu.com"), ("google.com", "http://www.google.com")] - for name, url in test_urls: - try: - req = urllib.request.Request(url) - req.set_proxy(f"{PROXY_HOST}:{PROXY_PORT}", "http") - urllib.request.urlopen(req, timeout=10) - log(f" {name} 可达") - except Exception as e: - log(f" {name} 不可达: {e}") - log("开启成功") - if use_pac: - log_red("注意:PAC 模式旨在开启代理时使微信等国内常用软件可以正常连接服务器, " - "但 PAC 对国外网站优化较差,开启时可能出现访问速度慢, 此时建议关闭 PAC.") - self._op_done.emit("start", True) - except Exception as e: - log(f"[错误] 开启失败: {e}") - self._op_done.emit("start", False) - - def on_stop(self): - """禁用按钮并在后台线程执行关闭流程。""" - self.btn_toggle.setEnabled(False) - threading.Thread(target=self._stop_thread, daemon=True).start() - - def _stop_thread(self): - """后台关闭代理:清理系统代理、停止 TLS/PAC 服务、断开 WG。""" - try: - log("正在关闭代理...") - proxy_off() - self._proxy_set_by_us = False - if self._using_wg: - log("正在断开 WireGuard...") - wg_off() - log("已关闭") - self._op_done.emit("stop", True) - except Exception as e: - log(f"[错误] 关闭失败: {e}") - self._op_done.emit("stop", False) - - def _on_mode_change(self, btn): - """切换直连 / WG 隧道时更新全局代理地址。 - - 两种模式最终都通过本地 127.0.0.1:1080 认证转发器访问 nginx 正向代理, - 由转发器根据模式选择 TLS 直连或 WG 网关作为上游。 - """ - global PROXY_HOST, PROXY_PORT - PROXY_HOST = "127.0.0.1" - PROXY_PORT = _TLS_LOCAL_PORT - log(f"连接方式 -> {btn.text()}") - - def _after_op(self, op, wg_ok): - """开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。""" - self.refresh_status() - self.btn_toggle.setEnabled(True) - # 恢复模式/PAC 控件的时机:关闭完成后、或开启失败时。 - # 不能靠按钮文字判断——refresh_status 是异步的,此刻按钮样式还没更新 - is_off = (op == "stop") or (op == "start" and not wg_ok) - self.rb_direct.setEnabled(is_off) - self.rb_wg.setEnabled(is_off) - self.cb_pac.setEnabled(is_off) - if op == "start" and not wg_ok: - QMessageBox.warning(self, "提示", "代理已设置,但 WireGuard 隧道未能连通,请检查配置。") - - # ── 日志 ── - def _append_log(self, msg: str): - """追加日志并自动滚动,最多保留 200 行。""" - self.log_text.append(msg) - scrollbar = self.log_text.verticalScrollBar() - scrollbar.setValue(scrollbar.maximum()) - if self.log_text.document().blockCount() > 200: - cursor = self.log_text.textCursor() - cursor.movePosition(cursor.Start) - cursor.select(cursor.BlockUnderCursor) - cursor.removeSelectedText() - cursor.deleteChar() - - # ── 状态刷新 ── - def refresh_status(self): - """触发一次后台状态轮询(不在 GUI 线程起子进程,避免周期性卡顿)。""" - if self._status_busy: - return - self._status_busy = True - # GUI 线程读取模式选择,传给后台线程;直连模式下不检测 WG,避免无谓的 wg 子进程 - wg_interested = self._using_wg or self.rb_wg.isChecked() - threading.Thread(target=self._poll_status, args=(wg_interested,), daemon=True).start() - - def _poll_status(self, wg_interested): - """后台线程:查询 WG 握手与系统代理状态,结果发回 GUI 线程。""" - try: - wg = _wg_active() if wg_interested else None - px = is_proxy_on() - except Exception: - wg, px = (False if wg_interested else None), False - self._status_ready.emit((wg, px)) - - def _apply_status(self, state): - """GUI 线程:根据轮询结果刷新状态指示灯与开关按钮。""" - self._status_busy = False - wg_on_, px_on_ = state - - if wg_on_ is None: - self.wg_dot.set_disabled() - self.wg_status.setText("未启用") - self.wg_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;") - elif wg_on_: - self.wg_dot.set_on() - self.wg_status.setText("已连接") - self.wg_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;") - else: - self.wg_dot.set_off() - self.wg_status.setText("未连接") - self.wg_status.setStyleSheet(f"color: {C_RED}; background: transparent;") - - if px_on_: - self.px_dot.set_on() - up, down = _traffic_snapshot() - # ↓ / ↑ 小字两行,压缩行高,整体只占一行的高度 - self.px_status.setText( - f'' - f'已开启 | ' - f'' - f'↓{_fmt_bytes(down)}' - f'↑{_fmt_bytes(up)}' - f'') - self.px_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;") - self._set_toggle_button_style(is_start=False) - else: - self.px_dot.set_off() - self.px_status.setText("已关闭") - self.px_status.setStyleSheet(f"color: {C_RED}; background: transparent;") - self._set_toggle_button_style(is_start=True) - - def _check_update(self): - """后台检查服务器是否有新版本。""" - try: - import json, urllib.request, base64 - domain = _endpoint_domain() - if domain == "???": - log("更新检查: 无法获取服务器域名") - self._update_done.emit() - return - log("正在检查更新...") - url = f"https://{domain}/latest_version.json" - req = urllib.request.Request(url) - auth = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode() - req.add_header("Authorization", f"Basic {auth}") - data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig") - info = json.loads(data) - log("客户端认证成功") - self._latest_version = info.get("latest_version", "") - self._download_url = info.get("download_url", "") - log(f"当前版本: {VERSION},最新版本: {self._latest_version}") - except Exception as e: - log(f"更新检查失败: {e}") - self._latest_version = "" - self._download_url = "" - finally: - self._update_done.emit() - - def _on_update_done(self): - """更新检查完成后恢复按钮;发现新版本时弹窗询问是否下载。""" - self.btn_toggle.setEnabled(True) - if self._latest_version and self._latest_version > VERSION: - reply = QMessageBox.question( - self, "发现新版本", - f"当前版本: {VERSION}\n最新版本: {self._latest_version}\n\n是否下载更新?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.Yes, - ) - if reply == QMessageBox.Yes: - threading.Thread(target=self._download_update, daemon=True).start() - - def _download_update(self): - """后台下载新版本 EXE 到 EXE 所在目录。""" - try: - import urllib.request, urllib.parse, base64 - url = self._download_url - if not url: - self._update_download_done.emit(False, "下载链接为空") - return - - # 对 URL 进行安全编码,兼容空格或已编码空格 - url = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%') - save_path = PERSIST_DIR / f"Royirin Rosmontis Private Proxy {self._latest_version}.exe" - log(f"正在下载新版本: {url}") - log(f"保存到: {save_path}") - - req = urllib.request.Request(url) - auth = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode() - req.add_header("Authorization", f"Basic {auth}") - - with urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=120) as resp: - data = resp.read() - - if len(data) < 1024: - self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE") - return - - save_path.write_bytes(data) - log(f"新版本已下载: {save_path}") - self._update_download_done.emit(True, str(save_path)) - except Exception as e: - log(f"下载更新失败: {e}") - self._update_download_done.emit(False, str(e)) - - def _on_update_download_done(self, success, message): - """下载完成后提示用户手动替换 EXE。""" - if success: - QMessageBox.information( - self, "下载完成", - f"新版本已下载到:\n{message}\n\n请关闭本程序后,用该文件替换当前运行的程序,然后重新启动。" - ) - else: - QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}") - - def eventFilter(self, obj, event): - """鼠标悬停到 PAC 复选框时异步加载规则摘要。""" - if obj is self.cb_pac and event.type() == QEvent.Enter: - if not self._pac_tooltip_loaded and not self._pac_tooltip_loading: - self._pac_tooltip_loading = True - # GUI 线程读取模式选择传参,避免子线程直接访问 Qt 控件 - use_wg = self.rb_wg.isChecked() - threading.Thread(target=self._load_pac_tooltip, args=(use_wg,), daemon=True).start() - return super().eventFilter(obj, event) - - def _load_pac_tooltip(self, use_wg): - """从服务器拉取 PAC 并解析出规则分类摘要;拉取失败则使用内置 PAC。""" - try: - import re, urllib.request - domain = _endpoint_domain() - if domain == "???": - self._pac_tooltip_ready.emit("PAC 规则模式\n\n无法获取服务器域名") - return - pac_name = "proxy_wg.pac" if use_wg else "proxy.pac" - url = f"https://{domain}/{pac_name}" - try: - req = urllib.request.Request(url) - req.add_header("Authorization", f"Basic {_PROXY_AUTH_BASIC}") - data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig") - except Exception as e: - # 服务器不可达时回退到内置规则,确保提示始终可用 - proxy_spec = f"{PROXY_HOST}:{PROXY_PORT}" - data = _build_pac(proxy_spec) - - # 按 "// 分类名" + "if (...)" 的格式解析 PAC,生成摘要 - blocks = re.findall(r'\n\s*//\s*([^\n]+?)\s*\n\s*if\s*\(([^/]*?)\)\s*\{', data, re.DOTALL) - - lines = ["PAC 规则模式", ""] - - total = 0 - for comment, conditions in blocks: - domains = re.findall(r'shExpMatch\(host,\s*"([^"]+)"\)', conditions) - total += len(domains) - if domains: - lines.append(f"• {comment}({len(domains)} 条)") - - lines[1] = f"共 {total} 条域名规则" - - tooltip = "\n".join(lines) - if len(tooltip) > 2000: - tooltip = tooltip[:2000] + "\n...(已截断)" - self._pac_tooltip_ready.emit(tooltip) - except Exception as e: - self._pac_tooltip_ready.emit(f"PAC 规则模式\n\n加载 PAC 失败: {e}") - finally: - self._pac_tooltip_loading = False - - def _set_pac_tooltip(self, text): - """设置并显示 PAC 规则摘要提示。""" - self.cb_pac.setToolTip(text) - self._pac_tooltip_loaded = True - if self.cb_pac.underMouse(): - QToolTip.showText( - self.cb_pac.mapToGlobal(self.cb_pac.rect().center()), - text, - self.cb_pac, - ) - - def quit_app(self): - """退出前清理本程序开启的代理与 WireGuard。 - - 只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。 - 本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。 - """ - if self._proxy_set_by_us: - proxy_off() - if self._using_wg: - wg_off() - QApplication.quit() - -# ── 程序入口 ────────────────────────────────────────── - -def _set_per_monitor_dpi_aware(): - """在创建 QApplication 前尽量设置为每显示器 DPI 感知, - - 使程序在 2K/4K 高分屏以及多显示器不同缩放比例之间移动时, - 由系统通知 WM_DPICHANGED 而不是被位图拉伸。 - """ - try: - # Windows 10 1607+:Per Monitor V2(最优) - ctypes.windll.user32.SetThreadDpiAwarenessContext(ctypes.c_ssize_t(-4)) - return - except Exception: - pass - try: - # Windows 8.1+:Per Monitor - ctypes.windll.shcore.SetProcessDpiAwareness(2) - return - except Exception: - pass - try: - # 最低兜底:System DPI Aware - ctypes.windll.user32.SetProcessDPIAware() - except Exception: - pass - - -_SINGLE_INSTANCE_MUTEX = None - -def _acquire_single_instance(): - """通过命名互斥体保证单实例运行;已有实例时返回 False。""" - global _SINGLE_INSTANCE_MUTEX - try: - kernel32 = ctypes.windll.kernel32 - _SINGLE_INSTANCE_MUTEX = kernel32.CreateMutexW(None, True, r"Local\RRPP_SingleInstance") - return kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS - except Exception: - return True # 互斥体创建失败时放行,避免把用户锁在门外 - -def main(): - """主入口:检查管理员权限、依赖文件,然后启动 GUI。""" - _set_per_monitor_dpi_aware() - - if not is_admin(): - elevate() - sys.exit(0) - - # 单实例:第二个实例直接退出(且不能执行下面的残留代理清理, - # 否则会把第一个实例正在使用的系统代理清掉) - if not _acquire_single_instance(): - app = QApplication(sys.argv) - QMessageBox.information(None, "提示", "程序已在运行中。") - sys.exit(0) - - # 上次异常退出可能残留指向 127.0.0.1:1080 的系统代理(转发器已随进程消失), - # 不清理会导致全网不通,启动时先清掉 - _cleanup_stale_proxy() - - # 任务栏图标:显式设置 AppUserModelID, - # 避免 Windows 依赖 exe 图标缓存推断(冷缓存下任务栏会显示空白/默认图标) - try: - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( - "RRPP.RoyirinRosmontisPrivateProxy") - except Exception: - pass - - missing = [] - if not WG_EXE.exists(): - missing.append(str(WG_EXE)) - if not WG_CLI.exists(): - missing.append(str(WG_CLI)) - if not WG_CONF.exists(): - missing.append(str(WG_CONF)) - if missing: - app = QApplication(sys.argv) - QMessageBox.critical(None, "缺少必需文件", - "以下文件缺失:\n " + "\n ".join(missing) + - "\n\n请确保 wg/ 目录和 jp_client.conf 与脚本同目录。") - sys.exit(1) - - # Qt5 默认把缩放因子四舍五入到整数倍:2K 屏常见的 150% 会被取整为 2x, - # 界面直接翻倍过大。改为 PassThrough,按实际比例(1.5x)缩放。 - os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") - try: - QApplication.setHighDpiScaleFactorRoundingPolicy( - Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) - except Exception: - pass - - QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) - QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) - app = QApplication(sys.argv) - icon = load_icon() - app.setWindowIcon(icon) - app.setQuitOnLastWindowClosed(True) - - window = MainWindow() - window.show() - # 开机自启等场景下任务栏/Explorer 可能尚未就绪, - # 延迟重设一次图标兜底,避免首次启动任务栏图标丢失 - QTimer.singleShot(2000, lambda: window.setWindowIcon(icon)) - - sys.exit(app.exec_()) - -if __name__ == "__main__": + + + +# 警告:带有 WireGuard 的完全版转发器永不再维护。 + + + + +# -*- coding: utf-8 -*- + +import sys, os, subprocess, time, ctypes, threading, ssl as _ssl, base64 +from ctypes import wintypes +from pathlib import Path + +# 私有 HTTPS 端点通常使用自签名/内部证书,创建免校验 SSL 上下文 +# 用于:更新检查、PAC 拉取、tooltip 加载等程序内部 HTTP 请求 +_SSL_NO_VERIFY = _ssl._create_unverified_context() + +from PyQt5.QtWidgets import ( + QApplication, QWidget, QLabel, QPushButton, + QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit, + QRadioButton, QButtonGroup, QCheckBox, QToolTip, +) +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QEvent +from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor + +VERSION = "202607192300" +UPDATE_USER = "" +UPDATE_PASSWORD = "" + +# 与 nginx 正向代理使用同一套 Basic 认证凭证 +_PROXY_AUTH_BASIC = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode() + +# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后, +# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。 +_BASE_WINDOW_WIDTH = 340 +_BASE_WINDOW_HEIGHT = 470 # 内容区 + 加大日志区 + 底部两行提示小字 + +# ── 配色(Qt StyleSheet 使用)──────────────────────────── +C_TITLE = "#8899aa" # 标题 / 副标题 +C_GREEN = "#51cf66" # 开启按钮、已连接状态 +C_YELLOW = "#ffd43b" # 提示色(保留) +C_RED = "#ff6b6b" # 关闭按钮、未连接 / 错误状态 +C_BG = "#ffffff" # 主背景 +C_CARD = "#f0f2f5" # 卡片背景 +C_BORDER = "#d0d5dd" # 边框 +C_TEXT = "#333333" # 正文 +C_SUBTEXT = "#889099" # 次要文字 + +# ── 路径 ────────────────────────────────────────────── +# frozen=True 表示已被 PyInstaller 打包为 EXE: +# BASE_DIR -> EXE 解压出的临时目录(_MEIPASS) +# PERSIST_DIR -> EXE 所在目录,服务安装等需要持久化的操作都放这里 +if getattr(sys, 'frozen', False): + BASE_DIR = Path(sys._MEIPASS) + PERSIST_DIR = Path(sys.executable).resolve().parent +else: + BASE_DIR = Path(__file__).resolve().parent + PERSIST_DIR = BASE_DIR + +WG_DIR = BASE_DIR / "wg" +WG_EXE = WG_DIR / "wireguard.exe" +WG_CLI = WG_DIR / "wg.exe" +WG_CONF = BASE_DIR / "jp_client.conf" +WG_SERVICE = "WireGuardTunnel$jp_client" + +# WireGuard 服务安装必须使用 PERSIST_DIR,不能依赖会被清理的 temp 目录 +_PERSIST_WG_EXE = PERSIST_DIR / "wg" / "wireguard.exe" +_PERSIST_WG_CONF = PERSIST_DIR / "jp_client.conf" + +def _endpoint_domain(): + """从 jp_client.conf 的 Endpoint 行解析服务器域名。""" + try: + for line in WG_CONF.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("Endpoint "): + addr = line.split("=", 1)[1].strip() + return addr.rsplit(":", 1)[0] + except Exception: + pass + return "???" + +def _endpoint_port(default=52810): + """从 jp_client.conf 的 Endpoint 行解析服务器端口。""" + try: + for line in WG_CONF.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("Endpoint "): + addr = line.split("=", 1)[1].strip() + return int(addr.rsplit(":", 1)[1]) + except Exception: + pass + return default + +def _listen_port(default=52810): + """从 jp_client.conf 的 ListenPort 行解析本地监听端口。""" + try: + for line in WG_CONF.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("ListenPort "): + return int(line.split("=", 1)[1].strip()) + except Exception: + pass + return default + +def _server_ip(): + """把 endpoint 域名解析为 IPv4 地址,仅用于界面显示。""" + try: + import socket + return socket.getaddrinfo(_endpoint_domain(), 443, socket.AF_INET, socket.SOCK_STREAM)[0][4][0] + except Exception: + return "???" + +def _tcp_endpoint_reachable(port=80, timeout=5): + """测试能否通过 TCP 连到服务器,用于区分"TCP 通但 UDP 被拦截"的场景。""" + try: + import socket + ip = socket.getaddrinfo(_endpoint_domain(), port, socket.AF_INET, socket.SOCK_STREAM)[0][4][0] + with socket.create_connection((ip, port), timeout=timeout): + return True + except Exception: + return False + +# ── 本地认证转发器(直连 / WG 模式均使用) ───────────── +# 本地 127.0.0.1:1080 作为透明 TCP 转发器, +# 直连模式通过 TLS 转发到远端服务器,WG 模式直接转发到 WG 网关。 +# 转发前会在首条 HTTP 请求中自动注入 Proxy-Authorization, +# 使 Windows 应用无需感知 nginx 正向代理的 Basic 认证。 +_TLS_LOCAL_PORT = 1080 +_tls_forwarder = None + +# 系统代理注册表路径,以及当前生效的代理地址 +PROXY_HOST = "127.0.0.1" +PROXY_PORT = _TLS_LOCAL_PORT +PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings" + +# ── PAC 内存服务 ────────────────────────────────────── +# Windows 无法跳过 HTTPS 证书校验下载远程 PAC,因此在本地 127.0.0.1 +# 启动一个极简 HTTP 服务,把 PAC 内容放在内存里供系统读取。 +_pac_server = None # {"server": TCPServer, "port": int} + +# 服务器 PAC 拉取失败时的回退:直接内置服务器上的完整 PAC(直连版本) +# 内容来源: https://tunnel.rrrosmontis.icu/proxy.pac +# 实际使用时根据当前模式替换其中的代理地址 +_BUILT_IN_PAC_DIRECT = r'''function FindProxyForURL(url, host) { + // RRPP 规则代理列表 — 仅以下域名走加密隧道 + var proxy = "PROXY 127.0.0.1:1080"; + // Google 全系 + Gemini + CDN + if (shExpMatch(host, "google.com") || + shExpMatch(host, "*.google.com") || + shExpMatch(host, "google.com.hk") || + shExpMatch(host, "*.google.com.hk") || + shExpMatch(host, "google.co.jp") || + shExpMatch(host, "*.google.co.jp") || + shExpMatch(host, "googleapis.com") || + shExpMatch(host, "*.googleapis.com") || + shExpMatch(host, "googleusercontent.com") || + shExpMatch(host, "*.googleusercontent.com") || + shExpMatch(host, "googletagmanager.com") || + shExpMatch(host, "*.googletagmanager.com") || + shExpMatch(host, "google-analytics.com") || + shExpMatch(host, "*.google-analytics.com") || + shExpMatch(host, "gstatic.com") || + shExpMatch(host, "*.gstatic.com") || + shExpMatch(host, "ggpht.com") || + shExpMatch(host, "*.ggpht.com") || + shExpMatch(host, "googlehosted.com") || + shExpMatch(host, "*.googlehosted.com") || + shExpMatch(host, "1e100.net") || + shExpMatch(host, "*.1e100.net") || + shExpMatch(host, "youtube.com") || + shExpMatch(host, "*.youtube.com") || + shExpMatch(host, "ytimg.com") || + shExpMatch(host, "*.ytimg.com") || + shExpMatch(host, "googlevideo.com") || + shExpMatch(host, "*.googlevideo.com") || + shExpMatch(host, "gemini.google.com") || + shExpMatch(host, "*.gemini.google.com") || + shExpMatch(host, "generativeai.googleapis.com") || + shExpMatch(host, "*.generativeai.googleapis.com") || + shExpMatch(host, "aistudio.google.com") || + shExpMatch(host, "*.aistudio.google.com") || + shExpMatch(host, "deepmind.google.com") || + shExpMatch(host, "*.deepmind.google.com") || + shExpMatch(host, "deepmind.com") || + shExpMatch(host, "*.deepmind.com")) { + return proxy; + } + + // OpenAI / ChatGPT 全系 + CDN + if (shExpMatch(host, "openai.com") || + shExpMatch(host, "*.openai.com") || + shExpMatch(host, "chatgpt.com") || + shExpMatch(host, "*.chatgpt.com") || + shExpMatch(host, "oaistatic.com") || + shExpMatch(host, "*.oaistatic.com") || + shExpMatch(host, "oaiusercontent.com") || + shExpMatch(host, "*.oaiusercontent.com") || + shExpMatch(host, "openaiusercontent.com") || + shExpMatch(host, "*.openaiusercontent.com")) { + return proxy; + } + + // Grok / xAI 全系 + CDN + if (shExpMatch(host, "grok.com") || + shExpMatch(host, "*.grok.com") || + shExpMatch(host, "x.com") || + shExpMatch(host, "*.x.com") || + shExpMatch(host, "twitter.com") || + shExpMatch(host, "*.twitter.com") || + shExpMatch(host, "twimg.com") || + shExpMatch(host, "*.twimg.com") || + shExpMatch(host, "x.ai") || + shExpMatch(host, "*.x.ai") || + shExpMatch(host, "xai.com") || + shExpMatch(host, "*.xai.com")) { + return proxy; + } + + // Meta 全系 + CDN + if (shExpMatch(host, "meta.com") || + shExpMatch(host, "*.meta.com") || + shExpMatch(host, "meta.ai") || + shExpMatch(host, "*.meta.ai") || + shExpMatch(host, "facebook.com") || + shExpMatch(host, "*.facebook.com") || + shExpMatch(host, "fb.com") || + shExpMatch(host, "*.fb.com") || + shExpMatch(host, "fbcdn.net") || + shExpMatch(host, "*.fbcdn.net") || + shExpMatch(host, "fbsbx.com") || + shExpMatch(host, "*.fbsbx.com") || + shExpMatch(host, "instagram.com") || + shExpMatch(host, "*.instagram.com") || + shExpMatch(host, "cdninstagram.com") || + shExpMatch(host, "*.cdninstagram.com") || + shExpMatch(host, "threads.net") || + shExpMatch(host, "*.threads.net") || + shExpMatch(host, "whatsapp.com") || + shExpMatch(host, "*.whatsapp.com") || + shExpMatch(host, "whatsapp.net") || + shExpMatch(host, "*.whatsapp.net") || + shExpMatch(host, "messenger.com") || + shExpMatch(host, "*.messenger.com")) { + return proxy; + } + + // Claude / Anthropic 全系 + CDN + if (shExpMatch(host, "anthropic.com") || + shExpMatch(host, "*.anthropic.com") || + shExpMatch(host, "claude.ai") || + shExpMatch(host, "*.claude.ai") || + shExpMatch(host, "claudeusercontent.com") || + shExpMatch(host, "*.claudeusercontent.com")) { + return proxy; + } + + // Cloudflare 全系 + if (shExpMatch(host, "cloudflare.com") || + shExpMatch(host, "*.cloudflare.com") || + shExpMatch(host, "cloudflare.net") || + shExpMatch(host, "*.cloudflare.net") || + shExpMatch(host, "cloudflareinsights.com") || + shExpMatch(host, "*.cloudflareinsights.com") || + shExpMatch(host, "cloudflarestatus.com") || + shExpMatch(host, "*.cloudflarestatus.com") || + shExpMatch(host, "cloudflarecdn.com") || + shExpMatch(host, "*.cloudflarecdn.com") || + shExpMatch(host, "cf-cdn.com") || + shExpMatch(host, "*.cf-cdn.com") || + shExpMatch(host, "workers.dev") || + shExpMatch(host, "*.workers.dev") || + shExpMatch(host, "pages.dev") || + shExpMatch(host, "*.pages.dev")) { + return proxy; + } + + // GitHub 全系 + if (shExpMatch(host, "github.com") || + shExpMatch(host, "*.github.com") || + shExpMatch(host, "githubusercontent.com") || + shExpMatch(host, "*.githubusercontent.com") || + shExpMatch(host, "githubassets.com") || + shExpMatch(host, "*.githubassets.com") || + shExpMatch(host, "github.io") || + shExpMatch(host, "*.github.io") || + shExpMatch(host, "githubcopilot.com") || + shExpMatch(host, "*.githubcopilot.com") || + shExpMatch(host, "ghcr.io") || + shExpMatch(host, "*.ghcr.io")) { + return proxy; + } + + // Hugging Face 全系 + CDN + if (shExpMatch(host, "huggingface.co") || + shExpMatch(host, "*.huggingface.co") || + shExpMatch(host, "hf.co") || + shExpMatch(host, "*.hf.co") || + shExpMatch(host, "huggingfacecdn.com") || + shExpMatch(host, "*.huggingfacecdn.com")) { + return proxy; + } + + // Microsoft Copilot 全系 + Bing + Azure CDN + if (shExpMatch(host, "microsoft.com") || + shExpMatch(host, "*.microsoft.com") || + shExpMatch(host, "bing.com") || + shExpMatch(host, "*.bing.com") || + shExpMatch(host, "bingapis.com") || + shExpMatch(host, "*.bingapis.com") || + shExpMatch(host, "bing.net") || + shExpMatch(host, "*.bing.net") || + shExpMatch(host, "live.com") || + shExpMatch(host, "*.live.com") || + shExpMatch(host, "microsoftonline.com") || + shExpMatch(host, "*.microsoftonline.com") || + shExpMatch(host, "microsoft365.com") || + shExpMatch(host, "*.microsoft365.com") || + shExpMatch(host, "office.com") || + shExpMatch(host, "*.office.com") || + shExpMatch(host, "office.net") || + shExpMatch(host, "*.office.net") || + shExpMatch(host, "azure.com") || + shExpMatch(host, "*.azure.com") || + shExpMatch(host, "azureedge.net") || + shExpMatch(host, "*.azureedge.net") || + shExpMatch(host, "azurefd.net") || + shExpMatch(host, "*.azurefd.net") || + shExpMatch(host, "msn.com") || + shExpMatch(host, "*.msn.com") || + shExpMatch(host, "msecnd.net") || + shExpMatch(host, "*.msecnd.net") || + shExpMatch(host, "msftncsi.com") || + shExpMatch(host, "*.msftncsi.com") || + shExpMatch(host, "s-microsoft.com") || + shExpMatch(host, "*.s-microsoft.com")) { + return proxy; + } + + // Steam 全系 + CDN / 社区 / 下载 + if (shExpMatch(host, "steampowered.com") || + shExpMatch(host, "*.steampowered.com") || + shExpMatch(host, "steamcommunity.com") || + shExpMatch(host, "*.steamcommunity.com") || + shExpMatch(host, "steam.com") || + shExpMatch(host, "*.steam.com") || + shExpMatch(host, "steamgames.com") || + shExpMatch(host, "*.steamgames.com") || + shExpMatch(host, "steamusercontent.com") || + shExpMatch(host, "*.steamusercontent.com") || + shExpMatch(host, "steamcontent.com") || + shExpMatch(host, "*.steamcontent.com") || + shExpMatch(host, "steamstatic.com") || + shExpMatch(host, "*.steamstatic.com") || + shExpMatch(host, "steam-chat.com") || + shExpMatch(host, "*.steam-chat.com") || + shExpMatch(host, "steam-api.com") || + shExpMatch(host, "*.steam-api.com") || + shExpMatch(host, "steamserver.net") || + shExpMatch(host, "*.steamserver.net") || + shExpMatch(host, "steamcdn-a.akamaihd.net") || + shExpMatch(host, "*.steamcdn-a.akamaihd.net") || + shExpMatch(host, "steamcommunity-a.akamaihd.net") || + shExpMatch(host, "*.steamcommunity-a.akamaihd.net") || + shExpMatch(host, "steamstore-a.akamaihd.net") || + shExpMatch(host, "*.steamstore-a.akamaihd.net") || + shExpMatch(host, "steamuserimages-a.akamaihd.net") || + shExpMatch(host, "*.steamuserimages-a.akamaihd.net") || + shExpMatch(host, "akamaihd.net") || + shExpMatch(host, "*.akamaihd.net") || + shExpMatch(host, "valvesoftware.com") || + shExpMatch(host, "*.valvesoftware.com")) { + return proxy; + } + + // Discord 全系 + if (shExpMatch(host, "discord.com") || + shExpMatch(host, "*.discord.com") || + shExpMatch(host, "discordapp.com") || + shExpMatch(host, "*.discordapp.com") || + shExpMatch(host, "discord.gg") || + shExpMatch(host, "*.discord.gg") || + shExpMatch(host, "discord.media") || + shExpMatch(host, "*.discord.media") || + shExpMatch(host, "discordapp.net") || + shExpMatch(host, "*.discordapp.net")) { + return proxy; + } + + // Telegram 全系 + if (shExpMatch(host, "telegram.org") || + shExpMatch(host, "*.telegram.org") || + shExpMatch(host, "t.me") || + shExpMatch(host, "*.t.me") || + shExpMatch(host, "telega.one") || + shExpMatch(host, "*.telega.one") || + shExpMatch(host, "telegram-cdn.org") || + shExpMatch(host, "*.telegram-cdn.org")) { + return proxy; + } + + // Reddit 全系 + if (shExpMatch(host, "reddit.com") || + shExpMatch(host, "*.reddit.com") || + shExpMatch(host, "redd.it") || + shExpMatch(host, "*.redd.it") || + shExpMatch(host, "redditmedia.com") || + shExpMatch(host, "*.redditmedia.com") || + shExpMatch(host, "redditstatic.com") || + shExpMatch(host, "*.redditstatic.com")) { + return proxy; + } + + // LINE 全系 + if (shExpMatch(host, "line.me") || + shExpMatch(host, "*.line.me") || + shExpMatch(host, "line-apps.com") || + shExpMatch(host, "*.line-apps.com") || + shExpMatch(host, "line-scdn.net") || + shExpMatch(host, "*.line-scdn.net")) { + return proxy; + } + + // Spotify 全系 + if (shExpMatch(host, "spotify.com") || + shExpMatch(host, "*.spotify.com") || + shExpMatch(host, "spotifycdn.com") || + shExpMatch(host, "*.spotifycdn.com") || + shExpMatch(host, "scdn.co") || + shExpMatch(host, "*.scdn.co")) { + return proxy; + } + + // Netflix 全系 + if (shExpMatch(host, "netflix.com") || + shExpMatch(host, "*.netflix.com") || + shExpMatch(host, "nflxvideo.net") || + shExpMatch(host, "*.nflxvideo.net") || + shExpMatch(host, "nflximg.net") || + shExpMatch(host, "*.nflximg.net") || + shExpMatch(host, "nflxext.com") || + shExpMatch(host, "*.nflxext.com")) { + return proxy; + } + + // Disney+ 全系 + if (shExpMatch(host, "disneyplus.com") || + shExpMatch(host, "*.disneyplus.com") || + shExpMatch(host, "disney-plus.net") || + shExpMatch(host, "*.disney-plus.net") || + shExpMatch(host, "dssott.com") || + shExpMatch(host, "*.dssott.com")) { + return proxy; + } + + // TikTok 全系 + if (shExpMatch(host, "tiktok.com") || + shExpMatch(host, "*.tiktok.com") || + shExpMatch(host, "tiktokcdn.com") || + shExpMatch(host, "*.tiktokcdn.com") || + shExpMatch(host, "tiktokv.com") || + shExpMatch(host, "*.tiktokv.com")) { + return proxy; + } + + // PlayStation 全系 + if (shExpMatch(host, "playstation.com") || + shExpMatch(host, "*.playstation.com") || + shExpMatch(host, "sonyentertainmentnetwork.com") || + shExpMatch(host, "*.sonyentertainmentnetwork.com")) { + return proxy; + } + + // Nintendo 全系 + if (shExpMatch(host, "nintendo.com") || + shExpMatch(host, "*.nintendo.com") || + shExpMatch(host, "nintendowifi.net") || + shExpMatch(host, "*.nintendowifi.net")) { + return proxy; + } + + // Epic Games 全系 + if (shExpMatch(host, "epicgames.com") || + shExpMatch(host, "*.epicgames.com") || + shExpMatch(host, "unrealengine.com") || + shExpMatch(host, "*.unrealengine.com")) { + return proxy; + } + + // DuckDuckGo / Brave Search + if (shExpMatch(host, "duckduckgo.com") || + shExpMatch(host, "*.duckduckgo.com") || + shExpMatch(host, "search.brave.com") || + shExpMatch(host, "*.search.brave.com")) { + return proxy; + } + + // Proton 全系 + if (shExpMatch(host, "proton.me") || + shExpMatch(host, "*.proton.me") || + shExpMatch(host, "protonmail.com") || + shExpMatch(host, "*.protonmail.com") || + shExpMatch(host, "protonvpn.com") || + shExpMatch(host, "*.protonvpn.com")) { + return proxy; + } + + // Stack Overflow / Stack Exchange 全系 + if (shExpMatch(host, "stackoverflow.com") || + shExpMatch(host, "*.stackoverflow.com") || + shExpMatch(host, "stackexchange.com") || + shExpMatch(host, "*.stackexchange.com") || + shExpMatch(host, "superuser.com") || + shExpMatch(host, "*.superuser.com") || + shExpMatch(host, "serverfault.com") || + shExpMatch(host, "*.serverfault.com")) { + return proxy; + } + + // Quora / Medium + if (shExpMatch(host, "quora.com") || + shExpMatch(host, "*.quora.com") || + shExpMatch(host, "medium.com") || + shExpMatch(host, "*.medium.com")) { + return proxy; + } + + // Pixiv 全系 + if (shExpMatch(host, "pixiv.net") || + shExpMatch(host, "*.pixiv.net") || + shExpMatch(host, "pximg.net") || + shExpMatch(host, "*.pximg.net")) { + return proxy; + } + + // ニコニコ 全系 + if (shExpMatch(host, "nicovideo.jp") || + shExpMatch(host, "*.nicovideo.jp") || + shExpMatch(host, "nico.ms") || + shExpMatch(host, "*.nico.ms")) { + return proxy; + } + + // EA / Origin / Apex 全系 + if (shExpMatch(host, "ea.com") || + shExpMatch(host, "*.ea.com") || + shExpMatch(host, "origin.com") || + shExpMatch(host, "*.origin.com") || + shExpMatch(host, "eaassets.com") || + shExpMatch(host, "*.eaassets.com") || + shExpMatch(host, "apexlegends.com") || + shExpMatch(host, "*.apexlegends.com")) { + return proxy; + } + + // Battle.net / Blizzard 全系 + if (shExpMatch(host, "battle.net") || + shExpMatch(host, "*.battle.net") || + shExpMatch(host, "blizzard.com") || + shExpMatch(host, "*.blizzard.com")) { + return proxy; + } + + // Ubisoft 全系 + if (shExpMatch(host, "ubisoft.com") || + shExpMatch(host, "*.ubisoft.com") || + shExpMatch(host, "ubi.com") || + shExpMatch(host, "*.ubi.com")) { + return proxy; + } + + // Riot Games / 英雄联盟 / Valorant 全系 + if (shExpMatch(host, "riotgames.com") || + shExpMatch(host, "*.riotgames.com") || + shExpMatch(host, "leagueoflegends.com") || + shExpMatch(host, "*.leagueoflegends.com") || + shExpMatch(host, "valorant.com") || + shExpMatch(host, "*.valorant.com")) { + return proxy; + } + + // GOG / itch.io 全系 + if (shExpMatch(host, "gog.com") || + shExpMatch(host, "*.gog.com") || + shExpMatch(host, "itch.io") || + shExpMatch(host, "*.itch.io")) { + return proxy; + } + + // Twitch 全系 + if (shExpMatch(host, "twitch.tv") || + shExpMatch(host, "*.twitch.tv") || + shExpMatch(host, "ttvnw.net") || + shExpMatch(host, "*.ttvnw.net") || + shExpMatch(host, "twitchcdn.net") || + shExpMatch(host, "*.twitchcdn.net")) { + return proxy; + } + + // Crunchyroll 全系 + if (shExpMatch(host, "crunchyroll.com") || + shExpMatch(host, "*.crunchyroll.com")) { + return proxy; + } + + // Amazon Prime Video 全系 + if (shExpMatch(host, "primevideo.com") || + shExpMatch(host, "*.primevideo.com")) { + return proxy; + } + + // Apple TV+ 全系 + if (shExpMatch(host, "tv.apple.com") || + shExpMatch(host, "tvplus.apple.com")) { + return proxy; + } + + // HBO Max / Hulu / Paramount+ 全系 + if (shExpMatch(host, "max.com") || + shExpMatch(host, "*.max.com") || + shExpMatch(host, "hulu.com") || + shExpMatch(host, "*.hulu.com") || + shExpMatch(host, "paramountplus.com") || + shExpMatch(host, "*.paramountplus.com")) { + return proxy; + } + + // Slack 全系 + if (shExpMatch(host, "slack.com") || + shExpMatch(host, "*.slack.com") || + shExpMatch(host, "slack-edge.com") || + shExpMatch(host, "*.slack-edge.com") || + shExpMatch(host, "slack-imgs.com") || + shExpMatch(host, "*.slack-imgs.com")) { + return proxy; + } + + // Zoom 全系 + if (shExpMatch(host, "zoom.us") || + shExpMatch(host, "*.zoom.us") || + shExpMatch(host, "zoom.com") || + shExpMatch(host, "*.zoom.com") || + shExpMatch(host, "zoomgov.com") || + shExpMatch(host, "*.zoomgov.com")) { + return proxy; + } + + // Signal 全系 + if (shExpMatch(host, "signal.org") || + shExpMatch(host, "*.signal.org")) { + return proxy; + } + + // Docker 全系 + if (shExpMatch(host, "docker.com") || + shExpMatch(host, "*.docker.com") || + shExpMatch(host, "docker.io") || + shExpMatch(host, "*.docker.io")) { + return proxy; + } + + // GitLab 全系 + if (shExpMatch(host, "gitlab.com") || + shExpMatch(host, "*.gitlab.com") || + shExpMatch(host, "gitlab.io") || + shExpMatch(host, "*.gitlab.io")) { + return proxy; + } + + // Vercel 全系 + if (shExpMatch(host, "vercel.com") || + shExpMatch(host, "*.vercel.com") || + shExpMatch(host, "vercel.app") || + shExpMatch(host, "*.vercel.app")) { + return proxy; + } + + // Netlify 全系 + if (shExpMatch(host, "netlify.com") || + shExpMatch(host, "*.netlify.com") || + shExpMatch(host, "netlify.app") || + shExpMatch(host, "*.netlify.app")) { + return proxy; + } + + // Perplexity / Poe / Midjourney 全系 + if (shExpMatch(host, "perplexity.ai") || + shExpMatch(host, "*.perplexity.ai") || + shExpMatch(host, "poe.com") || + shExpMatch(host, "*.poe.com") || + shExpMatch(host, "midjourney.com") || + shExpMatch(host, "*.midjourney.com")) { + return proxy; + } + + // Figma / Canva 全系 + if (shExpMatch(host, "figma.com") || + shExpMatch(host, "*.figma.com") || + shExpMatch(host, "canva.com") || + shExpMatch(host, "*.canva.com")) { + return proxy; + } + + // LinkedIn 全系 + if (shExpMatch(host, "linkedin.com") || + shExpMatch(host, "*.linkedin.com") || + shExpMatch(host, "licdn.com") || + shExpMatch(host, "*.licdn.com")) { + return proxy; + } + + // Pinterest 全系 + if (shExpMatch(host, "pinterest.com") || + shExpMatch(host, "*.pinterest.com") || + shExpMatch(host, "pinimg.com") || + shExpMatch(host, "*.pinimg.com")) { + return proxy; + } + + // Tumblr 全系 + if (shExpMatch(host, "tumblr.com") || + shExpMatch(host, "*.tumblr.com")) { + return proxy; + } + + // Snapchat 全系 + if (shExpMatch(host, "snapchat.com") || + shExpMatch(host, "*.snapchat.com") || + shExpMatch(host, "sc-cdn.net") || + shExpMatch(host, "*.sc-cdn.net")) { + return proxy; + } + + // Wikipedia / Wikimedia 全系 + if (shExpMatch(host, "wikipedia.org") || + shExpMatch(host, "*.wikipedia.org") || + shExpMatch(host, "wikimedia.org") || + shExpMatch(host, "*.wikimedia.org")) { + return proxy; + } + + // PayPal 全系 + if (shExpMatch(host, "paypal.com") || + shExpMatch(host, "*.paypal.com") || + shExpMatch(host, "paypalobjects.com") || + shExpMatch(host, "*.paypalobjects.com")) { + return proxy; + } + + // Patreon 全系 + if (shExpMatch(host, "patreon.com") || + shExpMatch(host, "*.patreon.com")) { + return proxy; + } + + // 通用 CDN + if (shExpMatch(host, "cloudfront.net") || + shExpMatch(host, "*.cloudfront.net") || + shExpMatch(host, "fastly.net") || + shExpMatch(host, "*.fastly.net") || + shExpMatch(host, "fastlylb.net") || + shExpMatch(host, "*.fastlylb.net") || + shExpMatch(host, "jsdelivr.net") || + shExpMatch(host, "*.jsdelivr.net") || + shExpMatch(host, "unpkg.com") || + shExpMatch(host, "*.unpkg.com") || + shExpMatch(host, "statically.io") || + shExpMatch(host, "*.statically.io") || + shExpMatch(host, "cloudinary.com") || + shExpMatch(host, "*.cloudinary.com") || + shExpMatch(host, "imgur.com") || + shExpMatch(host, "*.imgur.com")) { + return proxy; + } + + // linux.do + if (shExpMatch(host, "linux.do") || + shExpMatch(host, "*.linux.do")) { + return proxy; + } + + return "DIRECT"; +} +''' + +def _build_pac(proxy_spec: str) -> str: + """使用内置完整 PAC,并替换为当前模式对应的代理地址。""" + return _BUILT_IN_PAC_DIRECT.replace( + 'var proxy = "PROXY 127.0.0.1:1080";', + f'var proxy = "PROXY {proxy_spec}";' + ) +def _start_pac_server(pac_content: str) -> int: + """在 127.0.0.1 启动内存 PAC HTTP 服务,返回实际监听的端口。""" + global _pac_server + _stop_pac_server() + import http.server + import socketserver + + class _PACHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path in ("/proxy.pac", "/proxy_wg.pac"): + body = pac_content.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/x-ns-proxy-autoconfig") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # 避免在日志区输出大量 HTTP 访问日志 + + class _ReuseAddrTCPServer(socketserver.TCPServer): + allow_reuse_address = True + + srv = _ReuseAddrTCPServer(("127.0.0.1", 0), _PACHandler) + port = srv.server_address[1] + threading.Thread(target=srv.serve_forever, daemon=True).start() + _pac_server = {"server": srv, "port": port} + return port + +def _stop_pac_server(): + """停止本地 PAC HTTP 服务;shutdown() 可能阻塞,因此先关闭 socket 再异步收尾。""" + global _pac_server + if _pac_server: + srv = _pac_server["server"] + _pac_server = None + try: + # 先关闭 socket,让 serve_forever() 从 select/accept 中立即退出 + srv.socket.close() + except Exception: + pass + # 在后台线程里尝试优雅 shutdown,最多等 1 秒,避免阻塞 GUI + def _shutdown(): + try: + srv.shutdown() + except Exception: + pass + try: + srv.server_close() + except Exception: + pass + t = threading.Thread(target=_shutdown, daemon=True) + t.start() + t.join(timeout=1.0) + +# ── 日志输出 ────────────────────────────────────────── +# GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit +_log_func = None + +def log(msg: str): + """输出一行日志到 GUI 日志区。""" + if _log_func: + _log_func(msg) + +def log_red(msg: str): + """输出一行红色警告日志到 GUI 日志区。""" + if _log_func: + _log_func(f'{msg}') + +# ── 通用工具函数 ────────────────────────────────────── + +def _run(cmd, warn=True): + """无窗口执行子进程,失败时记录警告日志。""" + r = subprocess.run(cmd, capture_output=True, text=True, + creationflags=subprocess.CREATE_NO_WINDOW) + if warn and r.returncode != 0: + err = r.stderr.strip() if r.stderr else "" + log(f"[警告] {' '.join(str(c) for c in cmd)} -> {r.returncode} {err}") + return r + +def is_admin(): + """判断当前进程是否以管理员身份运行。""" + try: + return ctypes.windll.shell32.IsUserAnAdmin() + except: + return False + +def elevate(): + """非管理员时请求 UAC 提权并重新启动自身。""" + script = str(Path(__file__).resolve()) + cwd = str(Path(__file__).resolve().parent) + # SW_SHOWNORMAL(1):GUI 版本必须显示窗口;编译为 EXE 后由 uac_admin 清单处理 + ctypes.windll.shell32.ShellExecuteW( + None, "runas", sys.executable, f'"{script}"', cwd, 1 + ) + +def _wg_active(max_age=150): + """判断 jp_client 接口是否处于活跃状态。 + + 使用 wg show 的机器可读输出 latest-handshakes(Unix 时间戳), + 不再解析人类可读文本——不同 wg 版本的文案不一致 + (例如刚握手时有的版本输出 "now"),文本解析会误报"握手过旧"。 + """ + r = _run([str(WG_CLI), "show", "jp_client", "latest-handshakes"], warn=False) + out = (r.stdout or "").strip() + if not out: + return False + try: + # 每行格式: \t<最近握手的 Unix 时间戳>,0 表示从未握手 + latest = max(int(line.split()[-1]) for line in out.splitlines() if line.strip()) + except (ValueError, IndexError): + return False + if latest == 0: + return False + return (time.time() - latest) <= max_age + +def _wg_show(): + """返回 jp_client 接口完整状态文本,用于故障诊断。""" + r = _run([str(WG_CLI), "show", "jp_client"], warn=False) + return r.stdout or "" + +def _cleanup_legacy_wg_service(): + """清理旧版 WireGuard 服务名(jp_wg_client.conf 时期遗留)。""" + legacy = "WireGuardTunnel$jp_wg_client" + _run(["sc", "stop", legacy], warn=False) + _run(["sc", "delete", legacy], warn=False) + + +def _service_installed(name): + """通过 sc query 判断服务是否已安装。""" + r = _run(["sc", "query", name], warn=False) + return r.returncode == 0 and "SERVICE_NAME" in (r.stdout or "") + +def _service_running(name): + """通过 sc query 判断服务是否正在运行。""" + r = _run(["sc", "query", name], warn=False) + return r.returncode == 0 and "RUNNING" in (r.stdout or "") + +def _wait_service_state(name, running=True, timeout=15): + """轮询等待服务进入指定状态。""" + for _ in range(timeout): + if _service_running(name) == running: + return True + time.sleep(1) + return False + +def _stop_service(name): + """停止服务并等待其进入停止状态。""" + if not _service_running(name): + return True + _run(["sc", "stop", name], warn=False) + return _wait_service_state(name, running=False, timeout=15) + +def _delete_service(name): + """删除服务并等待其从 sc query 中消失。""" + _run(["sc", "delete", name], warn=False) + for _ in range(10): + if not _service_installed(name): + return True + time.sleep(1) + return False + +def _start_service(name): + """启动服务;1056 表示服务已在运行,也视为成功。""" + r = _run(["sc", "start", name], warn=False) + if r.returncode in (0, 1056): + return True + return False + +def _ensure_persist_wg_files(): + """确保 EXE 目录下存在 wireguard.exe 和 jp_client.conf,供安装服务使用。 + + 打包后 wg/ 和配置在 _MEIPASS 临时目录(BASE_DIR),会被系统清理, + 服务安装必须用持久路径,因此统一从 BASE_DIR 拷贝到 EXE 所在目录。 + """ + try: + import shutil + if not _PERSIST_WG_EXE.exists(): + persist_wg_dir = PERSIST_DIR / "wg" + persist_wg_dir.mkdir(parents=True, exist_ok=True) + for name in ("wireguard.exe", "wg.exe"): + dst = persist_wg_dir / name + if not dst.exists(): + shutil.copy2(str(WG_DIR / name), str(dst)) + log(f" 已拷贝 wg/ 到 {persist_wg_dir}") + if not _PERSIST_WG_CONF.exists(): + shutil.copy2(str(WG_CONF), str(_PERSIST_WG_CONF)) + log(f" 已拷贝配置到 {_PERSIST_WG_CONF}") + return True + except Exception as e: + log(f" 准备服务文件失败: {e}") + return False + +def _install_wg_service(): + """停止并删除旧服务后,用 PERSIST_DIR 中的最新配置重新安装。""" + log(" 安装 WireGuard 隧道服务...") + _cleanup_legacy_wg_service() + if not _ensure_persist_wg_files(): + return False + if _service_installed(WG_SERVICE): + _stop_service(WG_SERVICE) + _delete_service(WG_SERVICE) + ir = _run([str(_PERSIST_WG_EXE), "/installtunnelservice", str(_PERSIST_WG_CONF)]) + log(f" installtunnelservice -> 返回码 {ir.returncode}" + (f" (错误: {ir.stderr.strip()})" if ir.returncode != 0 and ir.stderr.strip() else "")) + if ir.returncode == 0: + # installtunnelservice 注册的服务默认是自动启动,会导致开机关机自连、 + # 程序一打开就显示"已连接"。改为手动启动,只在用户开启代理时拉起隧道。 + _run(["sc", "config", WG_SERVICE, "start=", "demand"], warn=False) + return ir.returncode == 0 + +def _wait_handshake(max_attempts=15, interval=2): + """等待 WireGuard 握手成功。""" + for attempt in range(max_attempts): + time.sleep(interval) + if _wg_active(): + log(f" 握手成功 (第{attempt + 1}次检查)") + return True + return False + + +def wg_on(): + """启动 WireGuard 隧道并等待握手成功。""" + if _wg_active(): + log("WireGuard 隧道已在线") + return True + + # 预检:解析端点域名 + ep = _endpoint_domain() + log(f"预检: 解析端点 {ep} ...") + try: + import socket + wg_port = _endpoint_port() + addr = socket.getaddrinfo(ep, wg_port, socket.AF_INET, socket.SOCK_DGRAM) + ip = addr[0][4][0] + log(f" DNS 解析 -> {ip}:{wg_port}") + except Exception as e: + log(f" DNS 解析失败: {e}") + return False + + log(f"正在启动 WireGuard 服务: {WG_SERVICE} ...") + + # 服务未安装则安装;已安装但未运行则尝试启动 + if not _service_installed(WG_SERVICE): + if not _install_wg_service(): + log("WireGuard 隧道未连通") + return False + elif not _service_running(WG_SERVICE): + if not _start_service(WG_SERVICE): + log(" 服务启动失败,尝试重新安装...") + if not _install_wg_service(): + log("WireGuard 隧道未连通") + return False + + # 等待服务真正进入 RUNNING 状态 + if not _wait_service_state(WG_SERVICE, running=True, timeout=15): + log(" 服务未进入运行状态") + log("WireGuard 隧道未连通") + return False + + log(" 等待握手完成...") + if _wait_handshake(): + log("WireGuard 隧道已连接") + return True + + log(" 握手超时,重启服务重试...") + _stop_service(WG_SERVICE) + _delete_service(WG_SERVICE) + if not _install_wg_service(): + log("WireGuard 隧道未连通") + return False + if not _wait_service_state(WG_SERVICE, running=True, timeout=15): + log(" 服务重启后未进入运行状态") + log("WireGuard 隧道未连通") + return False + + log(" 再次等待握手...") + if _wait_handshake(): + log("WireGuard 隧道已连接") + return True + + log(" 握手仍未完成,当前接口状态:") + show_out = _wg_show() + for line in show_out.splitlines(): + log(f" {line.rstrip()}") + + # 本地接口已启动但有发送无接收,说明对端未响应 UDP 握手 + if "0 B received" in show_out: + wg_port = _endpoint_port() + tcp_ok = _tcp_endpoint_reachable(port=80) + if tcp_ok: + log(f" 诊断: TCP/HTTP 到服务器可达,但 WireGuard UDP {wg_port} 无响应。") + log(f" 常见原因:服务器端 WG 服务未运行、密钥不匹配、或 UDP {wg_port} 被防火墙拦截。") + else: + log(" 诊断: TCP/HTTP 到服务器也不通,可能是服务器整体不可达或本地网络受限。") + + log("WireGuard 隧道未连通") + return False + +def wg_off(): + """停止 WireGuard 服务(即使握手已过期也尝试停止)。""" + _run(["sc", "stop", WG_SERVICE], warn=False) + return True + +def proxy_on(pac_url=None): + """设置 Windows 系统代理。pac_url 不为空时启用 PAC 模式,否则使用固定代理。""" + if pac_url: + # PAC 模式:仅设置 AutoConfigURL,ProxyEnable 置 0, + # 避免程序读到"固定代理已启用但服务器为空"的混乱状态 + _run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) + # PAC 模式:清理固定代理遗留项,避免两种设置冲突 + _run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False) + _run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False) + _run(["reg", "add", PROXY_REG, "/v", "AutoConfigURL", "/t", "REG_SZ", "/d", pac_url, "/f"]) + log(f"系统代理已设置 (PAC)") + else: + hp = f"{PROXY_HOST}:{PROXY_PORT}" + _run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]) + _run(["reg", "add", PROXY_REG, "/v", "ProxyServer", "/t", "REG_SZ", "/d", hp, "/f"]) + _run(["reg", "add", PROXY_REG, "/v", "ProxyOverride", "/t", "REG_SZ", "/d", "", "/f"]) + log(f"系统代理已设置: {hp}") + +def proxy_off(): + """关闭系统代理并停止 TLS 转发器 / PAC 本地服务。""" + log("正在关闭系统代理...") + _run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]) + # 清除 ProxyServer 和 AutoConfigURL,确保 PAC / 固定代理两种模式都能正确关闭 + _run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False) + _run(["reg", "delete", PROXY_REG, "/v", "AutoConfigURL", "/f"], warn=False) + log("系统代理已关闭") + _tls_stop() + _stop_pac_server() + +# ── 本地认证转发器(直连 / WG 模式均使用) ───────────────── + +# ── 流量统计 ────────────────────────────────────────── +# 所有代理流量都经过本地 127.0.0.1:1080 转发器(直连 / WG / PAC 均如此), +# 在转发器里计数即可覆盖全部模式。每次"开启代理"时清零,即"本次连接"流量。 +_traffic_lock = threading.Lock() +_traffic = {"up": 0, "down": 0} + +def _traffic_add(up=0, down=0): + with _traffic_lock: + _traffic["up"] += up + _traffic["down"] += down + +def _traffic_reset(): + with _traffic_lock: + _traffic["up"] = _traffic["down"] = 0 + +def _fmt_bytes(n): + f = float(n) + for unit in ("B", "KB", "MB", "GB", "TB"): + if f < 1024 or unit == "TB": + return f"{int(f)} {unit}" if unit == "B" else f"{f:.1f} {unit}" + f /= 1024 + +def _traffic_snapshot(): + with _traffic_lock: + return _traffic["up"], _traffic["down"] + +def _tls_start(remote_host, remote_port=443, use_tls=True): + """启动本地认证转发器:127.0.0.1:1080 -> remote_host:remote_port(可选 TLS 包裹)。""" + global _tls_forwarder + _tls_stop() + import socket, ssl, select + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + svr.bind(("127.0.0.1", _TLS_LOCAL_PORT)) + svr.listen(32) + svr.settimeout(1) + _tls_forwarder = {"sock": svr, "running": True} + + proto = "TLS" if use_tls else "TCP" + + def _run(): + while _tls_forwarder and _tls_forwarder.get("running"): + try: + client, addr = svr.accept() + threading.Thread(target=_tls_relay, args=(client, remote_host, remote_port, ctx, use_tls), daemon=True).start() + except socket.timeout: + continue + except Exception: + break + + t = threading.Thread(target=_run, daemon=True) + t.start() + log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})") + +_HTTP_METHODS = (b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ", + b"HEAD ", b"OPTIONS ", b"PATCH ") + +def _starts_with_method(data: bytes) -> bool: + return any(data.startswith(m) for m in _HTTP_METHODS) + +def _inject_proxy_auth(data: bytes) -> bytes: + """在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。 + + nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization, + 因此转发器向上游发送 Basic 认证时使用 Authorization 头。 + """ + if not _starts_with_method(data): + return data + end = data.find(b"\r\n\r\n") + if end == -1: + return data + headers_lower = data[:end].lower() + if b"authorization:" in headers_lower: + return data + auth_line = f"Authorization: Basic {_PROXY_AUTH_BASIC}\r\n".encode() + return data[:end + 2] + auth_line + data[end + 2:] + +def _make_request_injector(): + """为明文 HTTP 代理连接生成逐条注入认证的流式处理器。 + + keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。 + 请求头跨包时先缓冲,凑齐 \r\n\r\n(或超过 64KB)后统一处理; + 请求体数据不以方法开头,原样透传。 + 注意:仅用于非 CONNECT 连接;CONNECT 隧道的后续流量是加密的,不能触碰。 + """ + pending = b"" + def feed(data: bytes) -> bytes: + nonlocal pending + if pending: + data, pending = pending + data, b"" + if not _starts_with_method(data): + return data + if b"\r\n\r\n" in data or len(data) >= 65536: + return _inject_proxy_auth(data) + pending = data + return b"" + return feed + +def _tls_relay(client, remote_host, remote_port, ctx, use_tls): + """处理一条客户端连接:注入代理认证后,与远端双向转发。""" + import socket, select + try: + remote = socket.create_connection((remote_host, remote_port), 10) + if use_tls: + remote = ctx.wrap_socket(remote, server_hostname=remote_host) + except Exception as e: + log(f"[转发器] 连接上游失败: {e}") + client.close() + return + + # 读取首包,判定连接类型:CONNECT 隧道 / 明文 HTTP / 其他 + client.settimeout(2.0) + is_connect = False + injector = None + try: + buf = b"" + is_http = None + max_headers = 65536 + while len(buf) < max_headers: + try: + chunk = client.recv(4096) + except socket.timeout: + break + if not chunk: + break + buf += chunk + if is_http is None: + first_crlf = buf.find(b"\r\n") + if first_crlf != -1: + first_line = buf[:first_crlf] + is_http = _starts_with_method(first_line + b" ") + if not is_http: + break + if b"\r\n\r\n" in buf: + break + + if is_http and b"\r\n\r\n" in buf: + is_connect = buf.startswith(b"CONNECT ") + if not is_connect: + # 明文 HTTP 代理:keep-alive 后续请求也要注入认证 + injector = _make_request_injector() + buf = _inject_proxy_auth(buf) + if buf: + remote.sendall(buf) + _traffic_add(up=len(buf)) + except Exception as e: + log(f"[转发器] 读取首包失败: {e}") + client.close() + remote.close() + return + + client.settimeout(None) + remote.settimeout(None) + # CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时; + # 明文 HTTP 连接空闲 120 秒回收 + idle_timeout = None if is_connect else 120 + try: + while True: + r, _, _ = select.select([client, remote], [], [], idle_timeout) + if not r: + break + if client in r: + data = client.recv(65536) + if not data: + break + if injector is not None: + data = injector(data) # 请求头未收全时会被缓冲,返回空 + if data: + remote.sendall(data) + _traffic_add(up=len(data)) + if remote in r: + data = remote.recv(65536) + if not data: + break + client.sendall(data) + _traffic_add(down=len(data)) + except Exception: + pass + finally: + try: + client.close() + except Exception: + pass + try: + remote.close() + except Exception: + pass + +def _tls_stop(): + """停止 TLS 转发器监听线程。""" + global _tls_forwarder + if _tls_forwarder: + _tls_forwarder["running"] = False + try: + _tls_forwarder["sock"].close() + except Exception: + pass + _tls_forwarder = None + +def is_proxy_on(): + """系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。""" + r = _run(["reg", "query", PROXY_REG, "/v", "ProxyEnable"], warn=False) + if "0x1" in (r.stdout or ""): + return True + r = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False) + return "127.0.0.1" in (r.stdout or "") + +def _cleanup_stale_proxy(): + """清理上次异常退出残留的系统代理设置。 + + TLS 转发器 / PAC 服务只活在进程内,程序启动时若注册表仍指向 + 127.0.0.1:1080 或本地 PAC 地址,说明是残留——不清理会导致全网不通。 + """ + try: + if not is_proxy_on(): + return + r = _run(["reg", "query", PROXY_REG, "/v", "ProxyServer"], warn=False) + r2 = _run(["reg", "query", PROXY_REG, "/v", "AutoConfigURL"], warn=False) + stale = (f"{PROXY_HOST}:{PROXY_PORT}" in (r.stdout or "") + or "127.0.0.1" in (r2.stdout or "")) + if stale: + proxy_off() + except Exception: + pass + +# ── 图标 ────────────────────────────────────────────── + +# 程序图标 base64 编码(原 ico.ico),避免打包后依赖外部资源文件 +_ICO_B64 = "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" + +def load_icon(): + """加载程序图标,base64 解码失败时返回纯色兜底图标。""" + try: + import base64 + data = base64.b64decode(_ICO_B64) + pixmap = QPixmap() + pixmap.loadFromData(data) + if not pixmap.isNull(): + return QIcon(pixmap) + except Exception: + pass + # 兜底:生成一个纯色方形图标 + img = QImage(64, 64, QImage.Format_ARGB32) + img.fill(QColor(C_TITLE)) + return QIcon(QPixmap.fromImage(img)) + +# ── 状态指示灯(小圆点) ─────────────────────────────── + +class StatusDot(QLabel): + """用于 WireGuard / 系统代理状态的小圆点。""" + def __init__(self): + super().__init__() + self.setFixedSize(14, 14) + self.set_off() + + def set_on(self): + self.setStyleSheet(f""" + QLabel {{ + background-color: {C_GREEN}; + border-radius: 7px; + border: 1px solid rgba(81, 207, 102, 50); + }} + """) + + def set_off(self): + self.setStyleSheet(f""" + QLabel {{ + background-color: {C_RED}; + border-radius: 7px; + border: 1px solid rgba(255, 107, 107, 50); + }} + """) + + def set_disabled(self): + """未启用(直连模式下不检测 WireGuard)的中性灰色。""" + self.setStyleSheet(f""" + QLabel {{ + background-color: {C_BORDER}; + border-radius: 7px; + border: 1px solid rgba(136, 153, 170, 50); + }} + """) + +# ── 主窗口 ──────────────────────────────────────────── + +# ── 主窗口 ──────────────────────────────────────────── + +class MainWindow(QWidget): + log_signal = pyqtSignal(str) + _op_done = pyqtSignal(str, bool) + _update_done = pyqtSignal() + _update_download_done = pyqtSignal(bool, str) + _pac_tooltip_ready = pyqtSignal(str) + + _status_ready = pyqtSignal(object) + + def __init__(self): + super().__init__() + self._using_wg = False + self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的) + self._status_busy = False # 防止状态轮询线程堆叠 + # 把全局 log() 函数接到 GUI 日志区 + global _log_func + _log_func = self.log_signal.emit + self.log_signal.connect(self._append_log) + self._op_done.connect(self._after_op) + self._update_done.connect(self._on_update_done) + self._update_download_done.connect(self._on_update_download_done) + self._pac_tooltip_ready.connect(self._set_pac_tooltip) + self._status_ready.connect(self._apply_status) + + self.init_ui() + + # 每 2 秒刷新一次状态指示灯 + self.timer = QTimer() + self.timer.timeout.connect(self.refresh_status) + self.timer.start(2000) + self.refresh_status() + + # 启动时后台检查更新,完成前禁用开启按钮 + threading.Thread(target=self._check_update, daemon=True).start() + + def init_ui(self): + """初始化主界面布局与样式。""" + self.setWindowTitle("Royirin Rosmontis Private Proxy") + self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint) + self.setWindowIcon(load_icon()) + self.setFixedSize(_BASE_WINDOW_WIDTH, _BASE_WINDOW_HEIGHT) + + self.setStyleSheet(f""" + QWidget {{ + background-color: {C_BG}; + color: {C_TEXT}; + font-family: "Microsoft YaHei", "Segoe UI", sans-serif; + font-size: 13px; + }} + """) + + layout = QVBoxLayout(self) + layout.setSpacing(14) + layout.setContentsMargins(24, 20, 24, 20) + + # 标题 + title = QLabel("Royirin Rosmontis Private Proxy") + title.setFont(QFont("Microsoft YaHei", 16, QFont.Bold)) + title.setStyleSheet(f"color: {C_TITLE};") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # 节点副标题 + subtitle = QLabel(f"节点: {_endpoint_domain()} (JP)") + subtitle.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + # 连接方式选择区 + mode_layout = QHBoxLayout() + mode_layout.setSpacing(8) + mode_layout.addStretch() + self.mode_group = QButtonGroup(self) + self.rb_direct = QRadioButton("直连") + self.rb_wg = QRadioButton("WG 隧道") + for rb, t in [(self.rb_direct, "直连"), (self.rb_wg, "WG 隧道")]: + rb.setStyleSheet(f""" + QRadioButton {{ + color: {C_SUBTEXT}; font-size: 12px; + }} + QRadioButton::checked {{ + color: {C_TEXT}; font-weight: bold; + }} + """) + self.mode_group.addButton(rb) + mode_layout.addWidget(rb) + self.rb_direct.setChecked(True) + self.mode_group.buttonClicked.connect(self._on_mode_change) + + self.cb_pac = QCheckBox("PAC 规则模式") + self.cb_pac.setChecked(False) # 默认关闭 PAC,由用户按需开启 + self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") + self.cb_pac.setToolTip("PAC 规则模式\n\n悬停此处可加载当前规则摘要...") + self.cb_pac.installEventFilter(self) + self._pac_tooltip_loaded = False + self._pac_tooltip_loading = False + mode_layout.addWidget(self.cb_pac) + mode_layout.addStretch() + layout.addLayout(mode_layout) + + # 分隔线 + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setStyleSheet(f"background-color: rgba(136, 153, 170, 80); max-height: 1px;") + layout.addWidget(line) + + # WireGuard 状态卡片 + wg_frame = QFrame() + wg_frame.setStyleSheet(f""" + QFrame {{ + background-color: {C_CARD}; + border: 1px solid {C_BORDER}; + border-radius: 8px; + }} + """) + wg_layout = QHBoxLayout(wg_frame) + wg_layout.setContentsMargins(16, 12, 16, 12) + wg_label = QLabel("WireGuard 隧道") + wg_label.setStyleSheet(f"color: {C_TEXT}; font-weight: bold; background: transparent;") + self.wg_dot = StatusDot() + self.wg_status = QLabel("检测中...") + self.wg_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;") + wg_layout.addWidget(wg_label) + wg_layout.addStretch() + wg_layout.addWidget(self.wg_status) + wg_layout.addWidget(self.wg_dot) + layout.addWidget(wg_frame) + + # 系统代理状态卡片 + px_frame = QFrame() + px_frame.setStyleSheet(f""" + QFrame {{ + background-color: {C_CARD}; + border: 1px solid {C_BORDER}; + border-radius: 8px; + }} + """) + px_layout = QHBoxLayout(px_frame) + px_layout.setContentsMargins(16, 12, 16, 12) + px_label = QLabel("IE 代理") + px_label.setStyleSheet(f"color: {C_TEXT}; font-weight: bold; background: transparent;") + self.px_dot = StatusDot() + self.px_status = QLabel("检测中...") + self.px_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;") + px_layout.addWidget(px_label) + px_layout.addStretch() + px_layout.addWidget(self.px_status) + px_layout.addWidget(self.px_dot) + layout.addWidget(px_frame) + + layout.addStretch() + + # 开启 / 关闭 代理按钮 + self.btn_toggle = QPushButton("开启代理") + self.btn_toggle.setMinimumHeight(40) + self._set_toggle_button_style(is_start=True) + self.btn_toggle.clicked.connect(self.on_toggle) + self.btn_toggle.setEnabled(False) # 更新检查完成前禁用,避免旧版本被使用 + layout.addWidget(self.btn_toggle) + + # 日志输出区 + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setMaximumHeight(140) + self.log_text.setStyleSheet(f""" + QTextEdit {{ + background-color: {C_CARD}; + border: 1px solid {C_BORDER}; + border-radius: 6px; + color: {C_SUBTEXT}; + font-size: 11px; + padding: 4px 6px; + }} + QScrollBar:vertical {{ + width: 6px; + background: transparent; + }} + QScrollBar::handle:vertical {{ + background: {C_BORDER}; + border-radius: 3px; + min-height: 20px; + }} + """) + layout.addWidget(self.log_text) + + # 底部提示小字:独立容器收紧两行间距(主布局 spacing=14 太宽) + hints = QWidget() + hints_layout = QVBoxLayout(hints) + hints_layout.setContentsMargins(0, 0, 0, 0) + hints_layout.setSpacing(1) + + warn_label = QLabel("请勿二次分发此软件!") + warn_label.setStyleSheet(f"color: {C_RED}; font-size: 11px;") + warn_label.setAlignment(Qt.AlignCenter) + hints_layout.addWidget(warn_label) + + # 主背景是白色,纯白字不可见,用接近白的浅灰达到同样的弱化效果 + privacy_label = QLabel("使用此软件将会收集并上报您的IP地址及部分设备信息") + privacy_label.setStyleSheet("color: #c8ccd4; font-size: 9px;") + privacy_label.setAlignment(Qt.AlignCenter) + hints_layout.addWidget(privacy_label) + + layout.addWidget(hints) + + def closeEvent(self, event): + """关闭窗口时自动清理代理和隧道。""" + self.quit_app() + event.accept() + + # ── 开关操作 ── + def _set_toggle_button_style(self, is_start=True): + if is_start: + self.btn_toggle.setText("开启代理") + self.btn_toggle.setStyleSheet(f""" + QPushButton {{ + background-color: {C_GREEN}; color: #fff; border: none; + border-radius: 8px; padding: 10px 24px; + font-size: 14px; font-weight: bold; + }} + QPushButton:hover {{ background-color: #5ed978; }} + QPushButton:pressed {{ background-color: #45b85a; }} + QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }} + """) + else: + self.btn_toggle.setText("关闭代理") + self.btn_toggle.setStyleSheet(f""" + QPushButton {{ + background-color: {C_RED}; color: #fff; border: none; + border-radius: 8px; padding: 10px 24px; + font-size: 14px; font-weight: bold; + }} + QPushButton:hover {{ background-color: #ff8787; }} + QPushButton:pressed {{ background-color: #e05555; }} + QPushButton:disabled {{ background-color: #3a1e1e; color: #666; }} + """) + + def on_toggle(self): + """根据按钮当前文字判断是开启还是关闭。""" + if self.btn_toggle.text() == "开启代理": + self.on_start() + else: + self.on_stop() + + def on_start(self): + """禁用开关按钮与模式/PAC 控件,在后台线程执行开启流程。""" + self.btn_toggle.setEnabled(False) + self.rb_direct.setEnabled(False) + self.rb_wg.setEnabled(False) + self.cb_pac.setEnabled(False) + # 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全) + use_wg = self.rb_wg.isChecked() + use_pac = self.cb_pac.isChecked() + threading.Thread(target=self._start_thread, args=(use_wg, use_pac), daemon=True).start() + + def _start_thread(self, use_wg, use_pac): + """后台开启代理:启动 WG/TLS,加载 PAC,设置系统代理。""" + try: + self._using_wg = use_wg + _traffic_reset() # 流量统计按"本次连接"计,开启时清零 + if use_wg: + if not wg_on(): + log("WireGuard 隧道未连通,未设置代理") + self._op_done.emit("start", False) + return + log("WG 隧道模式 — 代理走加密隧道") + _tls_start("10.10.0.1", 80, use_tls=False) + else: + log("TLS直连模式") + ep = _endpoint_domain() + log(f" 目标域名: {ep}") + _tls_start(ep, 443, use_tls=True) + if use_pac: + domain = _endpoint_domain() + if domain == "???": + log("无法获取服务器域名,未设置 PAC") + self._op_done.emit("start", False) + return + pac_name = "proxy_wg.pac" if use_wg else "proxy.pac" + proxy_spec = f"{PROXY_HOST}:{PROXY_PORT}" + # Python 自带 SSL 跳过证书校验;Windows 直接取 HTTPS PAC 会因证书不信任失败 + remote_pac_url = f"https://{domain}/{pac_name}" + try: + import urllib.request + req = urllib.request.Request(remote_pac_url) + req.add_header("Authorization", f"Basic {_PROXY_AUTH_BASIC}") + data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig") + log(" 已从服务器加载 PAC") + # 兼容旧版 WG PAC(可能仍指向 10.10.0.1:80),统一替换为本地转发器 + data = data.replace('"PROXY 10.10.0.1:80"', f'"PROXY {proxy_spec}"') + except Exception as e: + log(f" 服务器 PAC 加载失败,使用内置规则: {e}") + data = _build_pac(proxy_spec) + # 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件 + pac_port = _start_pac_server(data) + pac_url = f"http://127.0.0.1:{pac_port}/{pac_name}" + log(f"使用 PAC: {pac_url}") + proxy_on(pac_url=pac_url) + else: + proxy_on() + self._proxy_set_by_us = True # 标记系统代理由本程序设置,退出时据此清理 + log("验证代理连通性...") + import urllib.request + if use_pac: + # PAC 模式下只验证代理服务器本身可达;具体域名路由交给 PAC 判断 + test_urls = [("google.com", "http://www.google.com")] + else: + test_urls = [("baidu.com", "http://www.baidu.com"), ("google.com", "http://www.google.com")] + for name, url in test_urls: + try: + req = urllib.request.Request(url) + req.set_proxy(f"{PROXY_HOST}:{PROXY_PORT}", "http") + urllib.request.urlopen(req, timeout=10) + log(f" {name} 可达") + except Exception as e: + log(f" {name} 不可达: {e}") + log("开启成功") + if use_pac: + log_red("注意:PAC 模式旨在开启代理时使微信等国内常用软件可以正常连接服务器, " + "但 PAC 对国外网站优化较差,开启时可能出现访问速度慢, 此时建议关闭 PAC.") + self._op_done.emit("start", True) + except Exception as e: + log(f"[错误] 开启失败: {e}") + self._op_done.emit("start", False) + + def on_stop(self): + """禁用按钮并在后台线程执行关闭流程。""" + self.btn_toggle.setEnabled(False) + threading.Thread(target=self._stop_thread, daemon=True).start() + + def _stop_thread(self): + """后台关闭代理:清理系统代理、停止 TLS/PAC 服务、断开 WG。""" + try: + log("正在关闭代理...") + proxy_off() + self._proxy_set_by_us = False + if self._using_wg: + log("正在断开 WireGuard...") + wg_off() + log("已关闭") + self._op_done.emit("stop", True) + except Exception as e: + log(f"[错误] 关闭失败: {e}") + self._op_done.emit("stop", False) + + def _on_mode_change(self, btn): + """切换直连 / WG 隧道时更新全局代理地址。 + + 两种模式最终都通过本地 127.0.0.1:1080 认证转发器访问 nginx 正向代理, + 由转发器根据模式选择 TLS 直连或 WG 网关作为上游。 + """ + global PROXY_HOST, PROXY_PORT + PROXY_HOST = "127.0.0.1" + PROXY_PORT = _TLS_LOCAL_PORT + log(f"连接方式 -> {btn.text()}") + + def _after_op(self, op, wg_ok): + """开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。""" + self.refresh_status() + self.btn_toggle.setEnabled(True) + # 恢复模式/PAC 控件的时机:关闭完成后、或开启失败时。 + # 不能靠按钮文字判断——refresh_status 是异步的,此刻按钮样式还没更新 + is_off = (op == "stop") or (op == "start" and not wg_ok) + self.rb_direct.setEnabled(is_off) + self.rb_wg.setEnabled(is_off) + self.cb_pac.setEnabled(is_off) + if op == "start" and not wg_ok: + QMessageBox.warning(self, "提示", "代理已设置,但 WireGuard 隧道未能连通,请检查配置。") + + # ── 日志 ── + def _append_log(self, msg: str): + """追加日志并自动滚动,最多保留 200 行。""" + self.log_text.append(msg) + scrollbar = self.log_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + if self.log_text.document().blockCount() > 200: + cursor = self.log_text.textCursor() + cursor.movePosition(cursor.Start) + cursor.select(cursor.BlockUnderCursor) + cursor.removeSelectedText() + cursor.deleteChar() + + # ── 状态刷新 ── + def refresh_status(self): + """触发一次后台状态轮询(不在 GUI 线程起子进程,避免周期性卡顿)。""" + if self._status_busy: + return + self._status_busy = True + # GUI 线程读取模式选择,传给后台线程;直连模式下不检测 WG,避免无谓的 wg 子进程 + wg_interested = self._using_wg or self.rb_wg.isChecked() + threading.Thread(target=self._poll_status, args=(wg_interested,), daemon=True).start() + + def _poll_status(self, wg_interested): + """后台线程:查询 WG 握手与系统代理状态,结果发回 GUI 线程。""" + try: + wg = _wg_active() if wg_interested else None + px = is_proxy_on() + except Exception: + wg, px = (False if wg_interested else None), False + self._status_ready.emit((wg, px)) + + def _apply_status(self, state): + """GUI 线程:根据轮询结果刷新状态指示灯与开关按钮。""" + self._status_busy = False + wg_on_, px_on_ = state + + if wg_on_ is None: + self.wg_dot.set_disabled() + self.wg_status.setText("未启用") + self.wg_status.setStyleSheet(f"color: {C_SUBTEXT}; background: transparent;") + elif wg_on_: + self.wg_dot.set_on() + self.wg_status.setText("已连接") + self.wg_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;") + else: + self.wg_dot.set_off() + self.wg_status.setText("未连接") + self.wg_status.setStyleSheet(f"color: {C_RED}; background: transparent;") + + if px_on_: + self.px_dot.set_on() + up, down = _traffic_snapshot() + # ↓ / ↑ 小字两行,压缩行高,整体只占一行的高度 + self.px_status.setText( + f'' + f'已开启 | ' + f'' + f'↓{_fmt_bytes(down)}' + f'↑{_fmt_bytes(up)}' + f'') + self.px_status.setStyleSheet(f"color: {C_GREEN}; background: transparent;") + self._set_toggle_button_style(is_start=False) + else: + self.px_dot.set_off() + self.px_status.setText("已关闭") + self.px_status.setStyleSheet(f"color: {C_RED}; background: transparent;") + self._set_toggle_button_style(is_start=True) + + def _check_update(self): + """后台检查服务器是否有新版本。""" + try: + import json, urllib.request, base64 + domain = _endpoint_domain() + if domain == "???": + log("更新检查: 无法获取服务器域名") + self._update_done.emit() + return + log("正在检查更新...") + url = f"https://{domain}/latest_version.json" + req = urllib.request.Request(url) + auth = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode() + req.add_header("Authorization", f"Basic {auth}") + data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig") + info = json.loads(data) + log("客户端认证成功") + self._latest_version = info.get("latest_version", "") + self._download_url = info.get("download_url", "") + log(f"当前版本: {VERSION},最新版本: {self._latest_version}") + except Exception as e: + log(f"更新检查失败: {e}") + self._latest_version = "" + self._download_url = "" + finally: + self._update_done.emit() + + def _on_update_done(self): + """更新检查完成后恢复按钮;发现新版本时弹窗询问是否下载。""" + self.btn_toggle.setEnabled(True) + if self._latest_version and self._latest_version > VERSION: + reply = QMessageBox.question( + self, "发现新版本", + f"当前版本: {VERSION}\n最新版本: {self._latest_version}\n\n是否下载更新?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + threading.Thread(target=self._download_update, daemon=True).start() + + def _download_update(self): + """后台下载新版本 EXE 到 EXE 所在目录。""" + try: + import urllib.request, urllib.parse, base64 + url = self._download_url + if not url: + self._update_download_done.emit(False, "下载链接为空") + return + + # 对 URL 进行安全编码,兼容空格或已编码空格 + url = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%') + save_path = PERSIST_DIR / f"Royirin Rosmontis Private Proxy {self._latest_version}.exe" + log(f"正在下载新版本: {url}") + log(f"保存到: {save_path}") + + req = urllib.request.Request(url) + auth = base64.b64encode(f"{UPDATE_USER}:{UPDATE_PASSWORD}".encode()).decode() + req.add_header("Authorization", f"Basic {auth}") + + with urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=120) as resp: + data = resp.read() + + if len(data) < 1024: + self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE") + return + + save_path.write_bytes(data) + log(f"新版本已下载: {save_path}") + self._update_download_done.emit(True, str(save_path)) + except Exception as e: + log(f"下载更新失败: {e}") + self._update_download_done.emit(False, str(e)) + + def _on_update_download_done(self, success, message): + """下载完成后提示用户手动替换 EXE。""" + if success: + QMessageBox.information( + self, "下载完成", + f"新版本已下载到:\n{message}\n\n请关闭本程序后,用该文件替换当前运行的程序,然后重新启动。" + ) + else: + QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}") + + def eventFilter(self, obj, event): + """鼠标悬停到 PAC 复选框时异步加载规则摘要。""" + if obj is self.cb_pac and event.type() == QEvent.Enter: + if not self._pac_tooltip_loaded and not self._pac_tooltip_loading: + self._pac_tooltip_loading = True + # GUI 线程读取模式选择传参,避免子线程直接访问 Qt 控件 + use_wg = self.rb_wg.isChecked() + threading.Thread(target=self._load_pac_tooltip, args=(use_wg,), daemon=True).start() + return super().eventFilter(obj, event) + + def _load_pac_tooltip(self, use_wg): + """从服务器拉取 PAC 并解析出规则分类摘要;拉取失败则使用内置 PAC。""" + try: + import re, urllib.request + domain = _endpoint_domain() + if domain == "???": + self._pac_tooltip_ready.emit("PAC 规则模式\n\n无法获取服务器域名") + return + pac_name = "proxy_wg.pac" if use_wg else "proxy.pac" + url = f"https://{domain}/{pac_name}" + try: + req = urllib.request.Request(url) + req.add_header("Authorization", f"Basic {_PROXY_AUTH_BASIC}") + data = urllib.request.urlopen(req, context=_SSL_NO_VERIFY, timeout=10).read().decode("utf-8-sig") + except Exception as e: + # 服务器不可达时回退到内置规则,确保提示始终可用 + proxy_spec = f"{PROXY_HOST}:{PROXY_PORT}" + data = _build_pac(proxy_spec) + + # 按 "// 分类名" + "if (...)" 的格式解析 PAC,生成摘要 + blocks = re.findall(r'\n\s*//\s*([^\n]+?)\s*\n\s*if\s*\(([^/]*?)\)\s*\{', data, re.DOTALL) + + lines = ["PAC 规则模式", ""] + + total = 0 + for comment, conditions in blocks: + domains = re.findall(r'shExpMatch\(host,\s*"([^"]+)"\)', conditions) + total += len(domains) + if domains: + lines.append(f"• {comment}({len(domains)} 条)") + + lines[1] = f"共 {total} 条域名规则" + + tooltip = "\n".join(lines) + if len(tooltip) > 2000: + tooltip = tooltip[:2000] + "\n...(已截断)" + self._pac_tooltip_ready.emit(tooltip) + except Exception as e: + self._pac_tooltip_ready.emit(f"PAC 规则模式\n\n加载 PAC 失败: {e}") + finally: + self._pac_tooltip_loading = False + + def _set_pac_tooltip(self, text): + """设置并显示 PAC 规则摘要提示。""" + self.cb_pac.setToolTip(text) + self._pac_tooltip_loaded = True + if self.cb_pac.underMouse(): + QToolTip.showText( + self.cb_pac.mapToGlobal(self.cb_pac.rect().center()), + text, + self.cb_pac, + ) + + def quit_app(self): + """退出前清理本程序开启的代理与 WireGuard。 + + 只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。 + 本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。 + """ + if self._proxy_set_by_us: + proxy_off() + if self._using_wg: + wg_off() + QApplication.quit() + +# ── 程序入口 ────────────────────────────────────────── + +def _set_per_monitor_dpi_aware(): + """在创建 QApplication 前尽量设置为每显示器 DPI 感知, + + 使程序在 2K/4K 高分屏以及多显示器不同缩放比例之间移动时, + 由系统通知 WM_DPICHANGED 而不是被位图拉伸。 + """ + try: + # Windows 10 1607+:Per Monitor V2(最优) + ctypes.windll.user32.SetThreadDpiAwarenessContext(ctypes.c_ssize_t(-4)) + return + except Exception: + pass + try: + # Windows 8.1+:Per Monitor + ctypes.windll.shcore.SetProcessDpiAwareness(2) + return + except Exception: + pass + try: + # 最低兜底:System DPI Aware + ctypes.windll.user32.SetProcessDPIAware() + except Exception: + pass + + +_SINGLE_INSTANCE_MUTEX = None + +def _acquire_single_instance(): + """通过命名互斥体保证单实例运行;已有实例时返回 False。""" + global _SINGLE_INSTANCE_MUTEX + try: + kernel32 = ctypes.windll.kernel32 + _SINGLE_INSTANCE_MUTEX = kernel32.CreateMutexW(None, True, r"Local\RRPP_SingleInstance") + return kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS + except Exception: + return True # 互斥体创建失败时放行,避免把用户锁在门外 + +def main(): + """主入口:检查管理员权限、依赖文件,然后启动 GUI。""" + _set_per_monitor_dpi_aware() + + if not is_admin(): + elevate() + sys.exit(0) + + # 单实例:第二个实例直接退出(且不能执行下面的残留代理清理, + # 否则会把第一个实例正在使用的系统代理清掉) + if not _acquire_single_instance(): + app = QApplication(sys.argv) + QMessageBox.information(None, "提示", "程序已在运行中。") + sys.exit(0) + + # 上次异常退出可能残留指向 127.0.0.1:1080 的系统代理(转发器已随进程消失), + # 不清理会导致全网不通,启动时先清掉 + _cleanup_stale_proxy() + + # 任务栏图标:显式设置 AppUserModelID, + # 避免 Windows 依赖 exe 图标缓存推断(冷缓存下任务栏会显示空白/默认图标) + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + "RRPP.RoyirinRosmontisPrivateProxy") + except Exception: + pass + + missing = [] + if not WG_EXE.exists(): + missing.append(str(WG_EXE)) + if not WG_CLI.exists(): + missing.append(str(WG_CLI)) + if not WG_CONF.exists(): + missing.append(str(WG_CONF)) + if missing: + app = QApplication(sys.argv) + QMessageBox.critical(None, "缺少必需文件", + "以下文件缺失:\n " + "\n ".join(missing) + + "\n\n请确保 wg/ 目录和 jp_client.conf 与脚本同目录。") + sys.exit(1) + + # Qt5 默认把缩放因子四舍五入到整数倍:2K 屏常见的 150% 会被取整为 2x, + # 界面直接翻倍过大。改为 PassThrough,按实际比例(1.5x)缩放。 + os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") + try: + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough) + except Exception: + pass + + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + app = QApplication(sys.argv) + icon = load_icon() + app.setWindowIcon(icon) + app.setQuitOnLastWindowClosed(True) + + window = MainWindow() + window.show() + # 开机自启等场景下任务栏/Explorer 可能尚未就绪, + # 延迟重设一次图标兜底,避免首次启动任务栏图标丢失 + QTimer.singleShot(2000, lambda: window.setWindowIcon(icon)) + + sys.exit(app.exec_()) + +if __name__ == "__main__": main() \ No newline at end of file