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