2119 lines
91 KiB
Python
2119 lines
91 KiB
Python
|
||
|
||
|
||
# 警告:带有 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'<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 = "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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'<table cellspacing="0" cellpadding="0"><tr>'
|
||
f'<td valign="middle">已开启 | </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() |