Files
Private-Proxy/full/proxy.py
T

2111 lines
93 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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'<span style="color:{C_RED};">{msg}</span>')
# ── 通用工具函数 ──────────────────────────────────────
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:
# 每行格式: <peer公钥>\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", "<local>", "/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 = "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAfqElEQVR4nMV7CZClV3Xed++/v33p193Ts7UGabQOICSDAWGBCImR4zgmjFhjIsAhKYeUK46xUpgaERuIVRRVxCmnAGODkyrioYxEMJjFILEYKO0aZkaj2VozPTO9v37rv94lde7/3kz3aEYMRCRX9dTT/d77/3vOPct3vnN+hhdoaa0ZAIsxJi76+7VS4hbLwi0AbmyvdncsL3dbUZiVm62yu3VbE4yxjHM+ALAKYB7A0wAeB/AogMOMMbnhejYAxRhTL8S+2QskOD+/Sa2ZBl4F4J8BuAPADQAKaaqxcHYVGhqNRgmVauFKLp8CeAbAQwC+AuB7jLFkdF9rpAiN/19L55vI/93vT2qt/8Nw5eyTy8ePaiW0WVJIfeCpE/LggTnR6w2l1lqNXlop82O8xn+nz4jRz4vXM1rrfVrrnRv3MDqEn2uxn1NwTj9I+72zZyccW7zfn5h5H7g9BTFE++hBnQgtF9bBU6/GZq+aYtNT9fF3zW3Zld1Zj15k7nz0otUF8DkAn2CMnR7v6YVyiys/dS3+tRbZvFo4rpPuitY6zbKoJ+PTB/WpR36kH3v4kO4PY32J0/55F1lFdv4XKdpaiHu0PuhuiA+/uKUffNDcQMfxtVqKb403Mlw6nZ07/LgylqtifeyRR/TBQyd1OhL6BRJ+41KbFCGyR3WavuKFcIlLLrrgyOwhkvAdUmYdc+cszGQ6VFrG+uzBJ7RMh/rU8Tl9+Oi8Xu2FZA/6F7mklEqmibmJzNJUp+nvbtjvFSmB/bQPjC9E/q61/hiAewABZKmEZRl30GDoL5zDmcV1OM1pVKtlZKkA40DRd1EIPDj2ec95QZdWCkwJCa04HJ8hy/4SBw68j916a3YlcYE/78W1Zvfeey8pgA07i38FiHvC9bNShEMNy7GSfhdLzxxGe/4UMm1D2AGq9SqyjIQ3X0NvmKA3CE3wi5IUUr2wcYpxDs25RaKoOBRwnLux56avaq1LJPzYci/7fTyP8OP3hxn2Fx38i/X+MMNwzSkGPtzqBFQSIpMSdP8TJ5dQn56GlNmmy5Lgk80SskxhtTuEbXG4tgXXseA5NhzbxqDfQ6lcBufPu9fLL62hlQQj5aosg1d0kCY/WO3175xotQaETS5nCfxywj+UozrVT/B5Er4dIYNfdKRTJI0j6nWguQWvUMXyYhtuqQQhBZQ2+zFLKY2C78KxbPTCCDYJqIEklegNYrQHMY4dncN6LzQWE8Up4lRASGm+e8WLMTBuQYJBgDtIogy2e5vL+P0YZYbLxQT7Mpe0XseY6Cb6Y2UX7yThGYND2Zi5JXSjHrjFsHr6GbJBhE4VW7ZVjeSZkJBSQSoNMsxqyccwjpGmZCn8vG1Yto2o1zObrzXqRuD1fgg6RIod3LIgohCO66BaKSHw3JELaVicgTMO0qdtWRSfzHU6gwhSCEzWSo6IB1ml2Xh9f3nxLytTW945ymDipypAa23wfCfUbyu7uKeTIGOAk78JWK4H22sBFNPiIRaOHEHzxTtRKfgbr2I2SgohoS3GUS0FUGQaOrcMpQSYTFCfnETg2lBKEZ4Gt5lBPqQqEiwc9FGrVcxVkySDIAVYDDa3YFvMxOGxUsvFAForAuawLMdBGmfleu0dJ48cOcCuu+6+Bx980H7d6163SQnsUggvjrFL2XhCKhQzaZS7yVVIEN8GuEhx4uizuP7Fu8lfcGUrB3XD3jpsx4UXFMf3Hut4YwTBoL2KcnPyCq89+hYpVyhACyo95MqZU1xDvmbqmj0/1Pv3W+yuu+TlLIAxzlQn0p8t2Sh3Qkg6wItvYEGj6HCcW+2jNdU0wtP+SQckiDHJSyyRpYgGPZTrE1AigVuubbhx/p3N32TG1aQUsKwrB3kUDwxelRZjFrgm4ypU/0JrfTMZEsWDcRHFLzJ9mWT6PVUPt3ciiEsJT4KWXCBWQJoK1GulkQBAe3UVUp5X7uYvAYh6awhKFSiRwrZHvmvO/FJLn09zSlBm+VkWAwUICtbgFld+SbS2bb8WwAdH2eC83HwkPO1EdTq6zhk+Qv/mGvzirZFbFxxtjHiYaFhMw/U9816/2zW+bdv2eXOmlYQ9KK0IQcKh+OG4kCKG7XpXhMUoyOVu8zOqgHGjPDqZQuBZiys9usjvaR29KPfiHB/wsSLIJKwAv2tzTEWZUhVfGygzFoVk8iwNhwOR4JCZMEFoLEA0DNGYaG5yAcrNWRIanJAlQzj+yN8pZ1t5XN3ot5ddGxT6s2khV4DtuiyJyGbhD2J738j8zSb5yB+oympo4Hf6GfQwg5VKoOIp4wN08jbTKNgag3RUn4oMnpsLIQT5qGUEC4e98/eXxtRz3yXL4yM/5lBGKeOVhF1kabhB2AsCp1n2c9M2+UFwMNuBkMrqSSgw/lat9e6RzJwswOxkkOFdZRfNTEBanLNIMKQZQ8lTcLhG4AKJZJDauBcUBaYRvhdZBse1kcQxtLhwkpTQbDs3dU1aHAlCKY8EVVIiSxMkw3W4jg+dJXQxaPMSUFIhSRODCf5vFqViMkwloUoep1P7d+O3bDooCoCdGO+NKbyP3IKUFxmBGSpefjADkQulx5hgDF21hOPYEEoZkLIxXWbRGoRMEPXXYNsc2ioijAWsbADGPcikC5/cISMoS8ojUJNH8oSUSdZltnlxirzyRZiEYgIdttmT1G/TWv8hY6xnfD/K8PLAxQ1hSizPhsxgNKGNIPSfa9G/R2+alDfWcG7+BP3G78s0RGd10QAXZVfMywmIFXIh20cRd1cgBm2ouAeXFBkPgCQ2L5amgEiR9tZgiT7EYAXZYBlRZwEiIe70ymLCeH9SKFiuS9iFUQXt2nwikvhVs3f6n5D4Tc9AoAvhlr5Lfh84Gv0E6CcMgQMENhUedEIsN2X6nOsZBRAa7y2cMH/LpEKmHfDlo8aKdLgEnQ7BZIzC4ml44Sp00ocrpQEtOkkAcgHyeRFDRTHSKISlhrDdGmy7CM5dRL0VyCzaJKi46PcNKsj3kpEVMZCMUcYMHhUCbzIKoECggH9kqNYN+ZHiZMnViFMGoRkou/cSBs8mJZCL0Knn/m7grm1BZyHSNMKg0wYLSrDiLoLlNuRgGSzsQdOJDtfgNq+D4wMs7sAiy0kTsDSBphcJk2aI4xR22oc1fwQy6oDBMi4jlAVrFFfGK4uHY8e85CJs4tvaxK9UwYoUmNR4jda6zHsJXqSB6+M82BoXIxUVHQ2hGBKy7NEbaqQEm4gOR4FZY31puEEZSW8Bbm0n+oMuev0IttTgW29CMSjAp9jfWYaibDB7HRyvCjsLoYU0J08BkBTAsgwqSUxWCNrnEPQciBNPATIB18zUAzLubzphY5LPkyoNGaM1YpKFM5Yk0LaNmVjgVl708EsFH77IKEXA2IdnUxEDhBmVmRfCzhgXDIkQylJ4ruEizb0pxRVqU2BZF9pvQKyfgSpVoRtNOMxBYeZG6GIDen3R3MRyJ+CkFPhICTE5J5jMINOBMVm7vwQvToCX3gZn8mpkZw4bJcAuIuqvG6FNZtmoiOesfO9knVG2wb8BRbWM1HgV4eRbSAzKFCbf87zQGVK+v0TIpewgFRBHCdwgGP0t/6Bf3QqvVIHn+ghkH97y4dw0lQAvBHAmd8KubTOpDoUSrPoWqPVjkEnH7FVkPWRJZD5vRz30pnejx10khRrsqV1Iu2fA4i7iMDbutnFPl7KAcUlCCiClblykDKlxqz3IcGPZA8qOZFHGTdCLszz9PbemoRqfIez2DdFhov+m4odKVRuuz+BOzEDMnwSYg0xrnHrsAPrnFrBtzw2Y2NmgQgLW9CxcJaDWz0JTppCS7gDZO4vjT57C6dOPYBD14DZbePVdb8b07hfDXjiO9fkDiIsegomrck7wpyRH3/cg0zXDNYwWz/LK/FoK9DsjUp5grOwzEAJMBJn+pS9GKUUMeqju2DKqAC8IT8dIeMCBBHdKYJUmrEYN3/6zz+JrH7kPTZFi++w2vPlzn0Rp9sXQSQR7x40Qrg/ZOWe+I9I16FIBh77yNRz6wZM4S1km7uK7n/wkbn//+/HP7/kDTEQhonOnoSeuMmeZH/6l3YAOiICU51jodwaoT5QQR4qJjNActtlgmJACcCiv08044FgaqSLWZfOFHJdh+dQCtrSqeaGx6Z6jclZLWKRpZsNp1PA399yLB/7k45j8lTfia9e/G3GqcPWKh9uu1lAJYCVD2K0dEEEAuXQMKg3hzezCbXe/By/992V84PA0njy4gNcc/St8ed+9WD4xh/d96r/BOv440kEPXrFsrGAjREqiIbjtwHHIuXPBpmdaOHhoDra9DdVGAXFk3L5sK6XKNuPwHMZ6sTZCF12AZdpAX/qdhPcDhjMnFlD0LRTKJWOuF0NUYnmIDnfsCJZnY+HUGr73iT/FLb/1b/CKj92HuR9IfPtYigeXl3CbTEfFiknksEstUFtVL83BSTKsBi3MT12LO8pFPBTuRPjKm/D6ygA/+ev78eirX42Xv/VOdDpr8IiLFGITBxGGAwTFChznQhzwPAfXXLMND//4KIqVEm665WqILJfXJb+PUgoKDJliGKTMlL0mdyoNz2M4duAU5p6ZQ7FUxJmTc8gIrV3sHsnQBBsRd8G0DW17+JU33I6XvenN+Mz313F9KcK79lB5akOG2QUobSJrBqfQgjf7CrDGdqSugwcOS8ytCvzWDQo7mw6ueeWrMVMpY/mJpwCvYEjYOBpR7UTKjA+CgqzxYY1Op5tnrjBCd0AVaxmNiYpxG00WT+QGBUgy+bG2hCbkx1F2lQFEmWDwi77x3x6lB9s1NcEG/zAnSaansxhuZYsJcpM7ZrDj9tvQuOV6vKVr4bqdDbRKwNOHu0ilQuCOdjF2IaKyKBX5LuqzW/E7u8vYMU1sMzC/0kfQvA3HPvd5vOQ33mjAk6Uz9Lp9+GNDHBULBHyoloiTFEvnltFZHyBMCQ062H3NDNxSgH5oMAFsoZAlkrkbIz790yC/lKHsagObtsxOmb/T5warqxdsa3RnQoXxYA2l8gQ4d6DJt0tFVGbqqAzamGQe+ssp2qcitHSEwPZM1UcbveC/I9whJLbWXBw8t4hVVkeBZWhawHBpEa967zux/dYboZeX4UIgSkPwYEPHWUsQHUju2e8PUKiUUW5NoUZY0hoVcUohcBhiwWCnig+4hYYQphA6LxWBFbpsLx1ZgtQGG1g2AyfcP6rz8zTIkfY7sLSCX6oDyQCMORBRB7OvuBEOMtTSDjoLIQqBj2KtAt0fAEEB2iW8wUH/XQgmGqXAxcu2ltFtrwFhHzWPQ+9swt3zBqgFyhgeuI7BpYJb3TKyQgaZpaOsQOW8MIowdYvQyEwaZBCSG5hvGdqLYW0z35ibs7YZtUbMr1QIEeoteQw+1zBvjRQwBiDh8kkUSk2zeUKmhhoXAkmokIYpVJSi5QB+FkN0egTgjZuQyWtBm97MJWoSzAZaHtCyCcAPDC8hu30oNzBNGTbswUracPyysSYTh6SAtiyzX7omKWA0kmACJVkJbS8UDJ7Fhhwa8yTQOJESEcZEBre9AmvYz+kSxjDIODybwdbCNEMNM0Cnz3mOzS0XXrmV+3EeiUyQ0vDAOguwVGYqxLw3mMcWc0ut8uKGbc4oZIxaaohMIEkERCKRDWPTLFFL58DoJynFoozrE0NjvpclMSzLHZOd4/7tZrJdQZNyAZwj2Z+2aJaHWu4Wh9PvoPjsATAqTamAmJxFtG03HJlAa8+0tKhbkwfv3OSyNEbQ3G4+L0UEizmQcggpNLjvwCrZsL0qtIxzytrxoMns6MXoDEiledS+2BKtwDdluYqoSjQFPTExgO0YziCob4WkapAp2F5gWCnbzRspeVv7uWwSeYTPYWVSHSNe8zE6E804s6RE8dQh8LAPbTvQlgN/ZR5OdwW60cJw+24MhhGmp2rm9BfnnjZ+VK5PGuori/tQdAKBhzQZQGkXzHGQqHlYrg1mN3LGmDbveQAVUwZQEdGSgY+Yn5F2wUhBfiGPEY4DnQVk46AEr4dDA7udUgvxYBV+bQZaJIZFKpbzIo0O/+IymZJMYJAaR5Txx4ilfjiMVcZd7tjtdbA0QjS7x+TlZGIHyscfhSUSDKZmkSQSNiRc10O/s4ZCpYFKcxK95bNwygHiqI+g0IAWQySphuYENBVkdRfCbA2F2i4wyzO8CwlnKEnK1+Qq5BOkADXuLjPj59qS4EEALT1j5hRw6XuDhZ/A23G1MXlYDrjnI1pfgGLe+SrVcxzEm/pMgMsB1wKPyJBs/JC7wDMMOGpyKXU1tUZarCLc8SI4YQc8GiAr1qECF8O1NsrVMsJhH47nozoxbfCA0swQIUoyWK6PeDiAAPn12JQ5UiEQdlYgdAxG9LjlglG5SXyd7UJ1l0zWMEogK7FsqP4qspWTIPaEmF0WlKEcjkF7Hqpah12ZMX2HQnUSKuqjs76SWwXheQI5tr2pp0eZzbeVlpyzOMNK0cbDNjVCe4l+MABu6AZlxTjj5aOPQBaqsCmdyQy6UEUaKhOtHaeIcDhEY6JlGpHnsUGvj8AvEBmImCoqq2hOk6o7aAHba0ChgGRlFaI4gFtpgTu5SRu3CAcGe1heMa/utDINFBZUciuxLKThGuL+EEo5KLd2Ix6so1ibhkxjtNeXoIMWAkuYJqvxdcoASV4GE69ZdgjZQtkWuM3xI8ZYO2eAFR4gwlf4BR5P7wInWNpbBUtjiEIN6dQOrM6fxfR0A912B9V640JkZZZpdcXDnhFIiQiS2fnkxqhtTSavuAvqxWi7ADEUiJbmkbRPQVFgdFzwyR3gQRVZ3DXkqhl68AMwt4AsWsdg9QzCzhBCe/ACB1Ik4F7ecW6vnEPGKyhVavB9CsA5THccB0qlxu+JDyTkN0w1NURJpQ8YK6HpiRLwg26CucDRVw2ndinmBdwerCKFD7V9FguLHbTqJSRxAtcP8iYIpcCRGqguiNcXobftRhp1QO0UU0mO+IK8V5cjPuP/FGDhQ547CSs6DNmchioUDDCyggaSwQIsr2SKJKklkv4Q2i6BgAGXISzbQao5ZNjL71vZZcy9VPBM5kmGPRQqE7BsF5yoep33NQaJ1rbLrV6KXsXF3xorIW+j8VMOfD4gbAyl7OktGM7uQXLVNehFGnLQx8RkDWtrXdSb9QskyMjUXLoZWYoEev0BFA1C0MkzDknkKQUu83n66UASXqdURkxPqmAvLMI5dhT82EHq6+cg6vFvw4picNeH5oGJDYb6IFdIIyTDEOH6PHgwAS1TBAXfnLDjFgyIEpTGGYNL/IQIkVGhpyErDiPvup8xRoONhtU0uK3s4c9TqYZFF1aUSC1SBS41Fk/M45prZnDuzBJq9SoutUrVBpxiBdnScfDuoml6KIsiMUV2EpwMLTOpjvrtpAQCSqnoQhmgQhZCkbyOrL8Kr7ELbHK7yUTUQ6ROrbQsKG5DZn3EUc+0rtnEjZCWDxpTLhRyes5E/1IVYW/d/LtYLqHXWUckDHvNIwEV+PjkBUzAzCQVWcFZz+J/YVOEFEy6AceZuWXMtMjkGZ565MT5SY1N/X8iSmyOYq0Jp30GXmcZeu0MhIigyNTp1G3yRQHJ6HdKbS4y0UVqcbAshSpWoK+/GdZLXgPtOMj6C7B3vQyssdXMIujes1AUa5CiGEYodTqwRQT4ZShuwXH4qCOVd2tsxwPjtqlObdeH7zpI+h1Z98HTTH3VY+yJ0QidPM9rU5M0BP6LVKpX8TjvrEVa9jqY2rEFj3z/EFaWaC6Im37dpjVSRrHWQtutoD11DezZl8CWoTFZwuWSTlGGRhHSsiF1BuHXkG15ObpX7Qa78WZYrRmI/hJ0dyUfjNAMllcB4y6KxTI4YiirgAgCSToETwY5JLf8EXurN++nUkevvWo6ILVaRVtRj2KVLAT8Q+eBBs73AfOhgSJj5+JEf5RiTfv0vNxF5KUS+PEPj2H7bAvPtyQVI0EdhUoTjaktqE9tBQ1OS3AI5kMkkYG/dPqKZrkKTXDLRb+6HSIZmsIoW30WVmUqb6Fpkb9oEm36OjjEFskYcVBCdPUrMSxM0NiqwQux1CYTbbRKSoGOV0BnZZkAgWxt22p126uf8Rh7anz6uGhMTu3fv98qBtYnjs8tH9jWqtrFRlGunV3DzLYmduwkBRDz8lzy0UR6i6PWrJu0ZCZAgiKKgWto9kajjEJzC0Q6gFZDVJoNtFoTmKz5qBaLyLrr5hAt5sMu1qA3zhoazsSGpuKH8H6phdbUdlgEzihVkmtoDpGOelsGeOWFWrXZxOkTcwpCWJBqoTU988HRYMR5M+YXLJnpvXv30rBAVuDi7karIAikdLtDfeOereBmXmQjg3NBeFNmihTf+dFxdFYWwGzbbKpQbeLMs8/i0/f/BH/z3TPwHGBiagp+ZdJ8hyixSrkM8m596iSSpVOIw3Xjv9R3NB11j+r+AfzwHCDJpz3D8we+b1gsY30SiKIoV8i4WTTiKfygoA4/epDBdt5HwCcX9cIp8s3unAfErbNbH4cQ/xFe2UqTTARFCirs0ifPGNZW2/i3H/suPvSlFF94cBGIu2bzllvA/GqCD3z+LP7ukEZ96zXmb+fvPiJdFAlz+inwm15lWnEijcEDD9xTWFlcwDcenMN/+qaPL3zrDGrFvIdf8oB6ANRKNtysjaKdj9nl03lEdppmXnbtLTfb7fUePVfwFRqT2/j4Da3njF6ZyQn6YLnxybX5szdPTjbfNUijTArtbJ7gIFDDwGSKP/7cQRxot7Dnhib+fn4Bv/LDZ3DHHb9sPrtzdiumG12UnRRZew7exNXn2RtzP0JszRnEN70M5YltpmbI1o7j2eOr+NJjAg8dA3rZNoSJQHD8EP7VrwtMFIB/eGIeX3pwDpVmCyXdxfVXx3jJ9Ro7d0zn52oGETnBwr+/7dde//uj5xyeM8HFcan12tdKmqf7r39+9L1O0f7WRLPhKEVFwahtTDUeTYIy4MmnF/H4UhmzM3WIJEalPolPfTvEuTPzRsgtEwVMlQ3IR38QorNyLk+jG5RAJGxCSI/OLO3j89+LcO9fL+HLBz2UfA+1ACjaAmsDhU4vAnSGx59exP94ooBvHq3gy3O7cN83HPz2n57ARz/zMDrttqDaWil1AJa1d2Tyl3y+yL6U/KPReH3vXhMs3rS6uPp35WL5NmiVMc2cnHLLU893nuqD03iLysysrmspnA1n8On7j+AD76kZNnnbhGOmypzGVWZaLOy3USg30Cfc4FfQXls3Pr++dBof/+Icvn58C7bXA+z05lGgAopNouYO4JYTPHbgFFpBil+7fTe+cfgZJH4BBY/IVBcWL+HLh4cisZbtD749eEao4M5ikXWeb2yeX9ICLqRGChiD1Q7uBNPfJlaCGDytlBkhowrwqbkUgcPPT4YIqWkqC8+ei/DsgScMHp8ocThMwLcUmq0pM2HW77ZRqE5jfW0Njl9CtVrFA986iP/1iI1f3hkisDJwu4w47KNiD8xzB2n5Btz7vwW+/sgCdu+exa//UhWdHsUbbpojWSayrVsm7a4oHHj29Pzri0V2dv9+A/IuO2dnX+6NsRJG2utrre9UQnyW2847hZSUS9RPjnX5woCjWMojMW2EGikElnhxAm65gpW5o2hYA7gFz3STyfLLjUl01pYw98whNKe2wg98ZGmESr0J21ZYiSsosGVq7CO1prA8LJprFwuAt7OCT/94DTdcfxJCSGRZRtFIxRnT1XrTubqVfPO+u3e8lTG2TsLfddfmoPczKWCshH379pESKDn/Sy3EQW5ZHyXr+YdD60Jxh0o+RrOWQuc9dmL5HBnCq78I6elDOLUwgD9Zz9PbaFis1pyCTlPUmxNmooRy/T957Y14cu4JHD97HPOxZ3I+NWodLmEzSuc0daaReg3cd38fbDhAubRNCNh2s1bEnu3iE/vetvX3Nxzc8wpP64qeUPjwhz9MF8zHaW37TzjwumEvPHh8vWhzy2JCKrIJw2pp7qLEeuiHQyTDGLMvvRl+rWlmDOl2G9v49S3bzc98ilyjVCzhQ++4ClvsJSwlVcN9OjwnNMy3R7OrJSfFUlqS0qnp2WpmT9Xt0//0pfZv3vv2Lb+XY6DLPyBx8eK4wkWBkTQ6yqXfO/Ldp18xOxN8ZFvTHzK3bsWCM0/0hKNjLbmPpiF7Ulh+GRkLUPSey86Oh6xozqC73sbK2VNw69vwj197PYosonHKDRsgYEN2piU9hVYsBlatXlUv3cH++5+9p3Lr3b/aemDvXnpiLN/rlcpl42dcNG8/qh5pROMPHz3S+5/7fzz4g7kV/+1xL3a5TNFjJVl0HdQDOkPO1taHuGHHeOT9wrTfuKpUSsINiiiViia9vurlV+MNR4/hoTlFg9n0sCDJTCSp5RdKVtUPsW0C999x3dTH3vDy1iP3vBvYu19bX7yLySue2v95FUBrNGbK7voi+K3XsSMA7v7+seTj33yo/9srfestZ9LydLExg8mduzDsD7DQSWWzaia7Rj6weZtkCK4XIChXyCp0sVjSb7+jpZ/4XJcPVY15nmcVXAslL17f2eL3375n4lN33lx7mB5hw979lt6/V12Jv/9C1r59mtMmxr9rret/9IUzb/nOj098QWt9+sjxRb3rbV/V3/rh3HOffhw9ULm+uqoXl1af8/4Xvn5M/8YfnVx8/6cW7v/4l1bfPRhognnjO/F9P+WJsP+na5/W/PZ9+ZOl46W1rh05vnLHu/74Rx88eHThS1rrw1LK7sUK6LbX9eKiUUBfSnlUa/23Wuv/HMfxG7XutTZJuVdbL6Tg/wcpnXblom8x+wAAAABJRU5ErkJggg=="
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'<table cellspacing="0" cellpadding="0"><tr>'
f'<td valign="middle">已开启&nbsp;|&nbsp;</td>'
f'<td valign="middle">'
f'<span style="font-size:8px; line-height:90%;">↓{_fmt_bytes(down)}'
f'<br/>↑{_fmt_bytes(up)}</span>'
f'</td></tr></table>')
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()