Files
Private-Proxy/lite/proxy_lite.py
T
RoyirinRosmontis e9f8321daf 修复:
- 退出登录按钮改为红色填充样式(与主界面关闭代理按钮一致)
2026-08-23 14:03:36 +08:00

2211 lines
98 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect
from pathlib import Path
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QPushButton,
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
QCheckBox, QMenu, QDialog, QLineEdit,
)
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
# 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
# ── 普通设置(config.json)────────────────────────────
# 版本号、更新链接等设置,缺失时用默认值兜底
_CONFIG = None
def _load_config():
"""加载 config.json;失败或缺失返回空字典(各设置用默认值兜底)。"""
global _CONFIG
if _CONFIG is not None:
return _CONFIG
_CONFIG = {}
try:
import json
path = _data_path("config.json")
if path is None:
log("config.json 缺失,使用默认设置")
return _CONFIG
data = json.loads(Path(path).read_text("utf-8"))
if not isinstance(data, dict):
raise ValueError("config.json 顶层应为对象")
_CONFIG = data
log(f"已加载普通设置: {len(_CONFIG)} 项 (config.json)")
except Exception as e:
log_red(f"config.json 加载失败: {e}")
return _CONFIG
def _config_val(key, default=""):
return _load_config().get(key, default)
# ── 节点配置(nodes.json)────────────────────────────
# 节点列表,当前固定取第一个(后续可扩展选择)
_NODES = None
def _load_nodes():
"""加载 nodes.json 节点列表(用户数据目录优先);失败或缺失时返回空列表。"""
global _NODES
if _NODES is not None:
return _NODES
_NODES = []
try:
import json
path = _user_path("nodes.json")
if path is None:
log_red("nodes.json 缺失,无法加载节点配置")
return _NODES
data = json.loads(_nodes_decrypt(Path(path).read_bytes()))
if not isinstance(data, list):
raise ValueError("nodes.json 顶层应为节点数组")
_NODES = data
log(f"已加载节点配置: {len(_NODES)} 个 ({path})")
if data and not str(data[0].get("domain") or "").strip():
log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新,"
"或使用 others/nodes_tool 导入节点配置")
except Exception as e:
log_red(f"nodes.json 加载失败: {e}")
return _NODES
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
def _current_node():
"""当前使用的节点:默认取列表第一个;无节点时返回 None。"""
nodes = _load_nodes()
if not nodes:
return None
i = _SELECTED_NODE
return nodes[i] if 0 <= i < len(nodes) else nodes[0]
def _selected_index():
"""当前选中节点下标。"""
return _SELECTED_NODE
def _switch_node(index):
"""切换当前节点下标;相同或越界返回 False。"""
global _SELECTED_NODE
nodes = _load_nodes()
if not nodes or not (0 <= index < len(nodes)):
log_red("节点切换失败: 列表为空或下标越界")
return False
if index == _SELECTED_NODE:
log("已在当前节点")
return False
_SELECTED_NODE = index
return True
def _reload_nodes():
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
global _NODES
_NODES = None
return _load_nodes()
def _node_val(key, default=""):
node = _current_node()
if not node:
return default
v = node.get(key, default)
return v if v not in (None, "") else default # 空字符串视为未填写
def _endpoint_domain():
return _node_val("domain", "???")
def _endpoint_port():
return int(_node_val("port", 443))
def _endpoint_country():
return _node_val("country", "")
def _proxy_auth_basic():
"""与 nginx 正向代理使用同一套 Basic 认证凭证(取自 nodes.json)。"""
node = _current_node()
if not node:
return ""
return base64.b64encode(f"{node.get('username', '')}:{node.get('password', '')}".encode()).decode()
_tls_forwarder = None
_pac_server = None
PROXY_HOST = "127.0.0.1"
PROXY_REG = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings"
# _TLS_LOCAL_PORT / PROXY_PORT 定义在 _data_path 之后(取自 config.json local_port)
# PAC 模式使用全量转发 PAC:所有流量送进本地转发器按 IP 智能分流
# (中国 IP 直连、其余走加密隧道);域名表在 pac_domains.json。
_BUILT_IN_PAC = r'''function FindProxyForURL(url, host) {
var proxy = "PROXY 127.0.0.1:1080";
return proxy;
}
'''
def _build_pac(proxy_spec: str) -> str:
"""生成全量转发 PAC,替换为当前模式对应的代理地址。"""
return _BUILT_IN_PAC.replace(
'var proxy = "PROXY 127.0.0.1:1080";',
f'var proxy = "PROXY {proxy_spec}";'
)
_LAN_BYPASS_PATTERNS = ["10.*", "192.168.*", "169.254.*", "127.*"]
_LAN_BYPASS_PATTERNS += [f"172.{i}.*" for i in range(16, 32)]
_LAN_BYPASS_JS = (
" // LAN bypass: RFC1918 / link-local / loopback (resolves domain first)\n"
" var _lanIp = dnsResolve(host);\n"
" if (_lanIp && (" + " ||\n".join(f' shExpMatch(_lanIp, "{p}")' for p in _LAN_BYPASS_PATTERNS) + ")) {\n"
" return \"DIRECT\";\n"
" }\n"
)
def _apply_lan_bypass(pac_content: str) -> str:
"""Inject LAN-direct rules into PAC so private IPs skip the local forwarder."""
marker = "function FindProxyForURL(url, host) {"
if marker not in pac_content:
return pac_content
return pac_content.replace(marker, marker + "\n" + _LAN_BYPASS_JS, 1)
def _proxy_override(bypass_lan: bool = False) -> str:
"""ProxyOverride for fixed-proxy mode; append common LAN ranges when enabled."""
if not bypass_lan:
return "<local>"
parts = ["<local>", "10.*"]
parts += [f"172.{i}.*" for i in range(16, 32)]
parts += ["192.168.*", "169.254.*", "127.*"]
return ";".join(parts)
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 == "/proxy.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() 唤醒 serve_forever 后优雅退出。"""
global _pac_server
if _pac_server:
srv = _pac_server["server"]
_pac_server = None
# 不要直接 close socket:serve_forever 的 select 仍注册着它,Windows 上
# 对已关闭 socket 做 select 会抛 WinError 10038(非套接字操作)。
# shutdown() 让 select 在 poll_interval(0.5s)内返回并正常退出。
srv.daemon_threads = True # 请求处理线程不阻塞 serve_forever 退出
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=getattr(subprocess, "CREATE_NO_WINDOW", 0))
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 _notify_proxy_change():
"""通知 WinINET 和顶层窗口代理设置已变更,让已运行程序立即重读。"""
try:
import ctypes
ctypes.windll.Wininet.InternetSetOptionW(None, 39, None, 0)
ctypes.windll.Wininet.InternetSetOptionW(None, 37, None, 0)
result = ctypes.c_ulong()
ctypes.windll.user32.SendMessageTimeoutW(
0xFFFF, 0x001A, 0,
ctypes.c_wchar_p("InternetSettings"),
0x0002, 5000, ctypes.byref(result))
except Exception:
pass
def proxy_on(pac_url=None, bypass_lan=False):
"""设置 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("系统代理已设置 (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", _proxy_override(bypass_lan), "/f"])
log(f"系统代理已设置: {hp}")
_notify_proxy_change()
def proxy_off():
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
log("正在关闭系统代理...")
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
# 清除 ProxyServer / ProxyOverride / AutoConfigURL,确保两种模式都清理干净
_run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "AutoConfigURL", "/f"], warn=False)
log("系统代理已关闭")
_notify_proxy_change()
_tls_stop()
_stop_pac_server()
# 打印本次转发流量统计
up, down = _traffic_snapshot()
total = up + down
log(f"本次转发流量共 {_fmt_bytes(total)}")
# ── 本地认证转发器 ─────────────────────────────────────
# ── 流量统计 ──────────────────────────────────────────
# 所有代理流量都经过本地转发器(直连 / PAC 均如此),转发器里计数即可覆盖全部模式;
# 每次"开启代理"时清零,即"本次连接"流量。
_traffic_lock = threading.Lock()
_traffic = {"up": 0, "down": 0}
_traffic_prev = {"up": 0, "down": 0, "ts": 0.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
_traffic_prev["up"] = _traffic_prev["down"] = 0
_traffic_prev["ts"] = time.time()
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_rate():
with _traffic_lock:
now = time.time()
up_total = _traffic["up"]
down_total = _traffic["down"]
prev_up = _traffic_prev["up"]
prev_down = _traffic_prev["down"]
prev_ts = _traffic_prev["ts"]
if prev_ts > 0 and now > prev_ts:
dt = now - prev_ts
up_rate = int((up_total - prev_up) / dt)
down_rate = int((down_total - prev_down) / dt)
else:
up_rate = down_rate = 0
_traffic_prev["up"] = up_total
_traffic_prev["down"] = down_total
_traffic_prev["ts"] = now
return up_rate, down_rate, up_total, down_total
def _traffic_snapshot():
with _traffic_lock:
return _traffic["up"], _traffic["down"]
def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False, quiet=False):
"""启动本地认证转发器:127.0.0.1:<local_port> -> remote_host:remote_port(可选 TLS 包裹)。
smart=True 时启用 IP 智能分流(域名表/非中国 IP 走隧道,中国 IP 直连)。
quiet=True 时不输出启动日志(临时转发器用)。
"""
global _tls_forwarder
_tls_stop()
import socket, ssl
# 服务器证书为 Let's Encrypt 正式证书,默认上下文完整校验
ctx = ssl.create_default_context()
# Windows 上 Hyper-V/WSL 的端口保留区间会动态变动,bind 可能瞬时被拒
# (WinError 10013/10048);重试 3 次,间隔 1 秒,每次用全新 socket
last_err = None
svr = None
for attempt in range(3):
svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
svr.bind(("127.0.0.1", _TLS_LOCAL_PORT))
break
except OSError as e:
last_err = e
svr.close()
svr = None
if attempt < 2:
log(f"端口 {_TLS_LOCAL_PORT} 绑定失败: {e},1 秒后重试 ({attempt + 1}/3)")
time.sleep(1)
if svr is None:
raise last_err
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, bypass_lan, smart), daemon=True).start()
except socket.timeout:
continue
except Exception:
break
t = threading.Thread(target=_run, daemon=True)
t.start()
if not quiet:
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
@functools.lru_cache(maxsize=256)
def _is_private_ip(host: str) -> bool:
"""判断目标是否命中常用内网/回环 IPv4;域名会解析后判断。"""
try:
import ipaddress
ip = ipaddress.ip_address(host)
addresses = [ip]
except ValueError:
try:
import socket
addresses = []
for addr in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM):
try:
addresses.append(ipaddress.ip_address(addr[4][0]))
except ValueError:
pass
except Exception:
return False
for ip in addresses:
if ip.version != 4:
continue
n = int(ip)
if (
(n & 0xFF000000) == 0x0A000000 or
(n & 0xFFF00000) == 0xAC100000 or
(n & 0xFFFF0000) == 0xC0A80000 or
(n & 0xFF000000) == 0x7F000000 or
(n & 0xFFFF0000) == 0xA9FE0000
):
return True
return False
# ── IP 智能分流(域名表优先,非中国 IP 走隧道,中国 IP 直连) ─────
_CN_RANGES = None # 合并排序后的 (start, end) 整数段
_CN_RANGES_LOADED = False
_FORCE_PROXY_SUFFIXES = None
_FORCE_PROXY_LOADED = False
def _data_path(name: str):
"""数据文件路径:只取打包内置目录(config.json 随 exe 内置)。"""
p = Path(BASE_DIR) / name
return p if p.is_file() else None
# 用户数据目录:Windows 惯例 %LOCALAPPDATA%(C:\Users\<用户>\AppData\Local),
# 非 Windows 环境回退用户主目录;测试可覆盖
_USER_DIR = (Path(os.environ["LOCALAPPDATA"]) if os.name == "nt" and os.environ.get("LOCALAPPDATA")
else Path.home()) / "RRPP"
def _user_path(name: str):
"""用户数据文件路径:dev 模式(未打包)源码目录优先,方便本地测试;
打包后用户数据目录优先(首启模板/在线更新写在这里)。"""
if not getattr(sys, 'frozen', False):
p = _data_path(name)
if p is not None:
return p
p = _USER_DIR / name
if p.is_file():
return p
return _data_path(name)
# ── 本地设置(settings.json,用户数据目录,明文) ─────────────
def _settings_path():
return _USER_DIR / "settings.json"
def _settings_load():
"""读取本地设置;缺失或损坏返回空字典。"""
try:
import json
f = _settings_path()
if not f.is_file():
return {}
data = json.loads(f.read_text("utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _settings_save(data):
"""本地设置原子写回。"""
import json
_USER_DIR.mkdir(parents=True, exist_ok=True)
tmp = _settings_path().with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8")
os.replace(tmp, _settings_path())
def _parse_port(text):
"""解析端口号;非法(非数字或超出 1-65535)返回 None。"""
try:
port = int(str(text).strip())
except (TypeError, ValueError):
return None
return port if 1 <= port <= 65535 else None
def _port_val():
"""代理转发端口:本地设置 > 内置 config.json > 默认 1080。"""
port = _parse_port(_settings_load().get("local_port"))
if port is not None:
return port
port = _parse_port(_config_val("local_port", 1080))
return port if port is not None else 1080
def _set_local_port(port):
"""更新代理转发端口并写回设置文件(下次开启代理生效)。"""
global _TLS_LOCAL_PORT, PROXY_PORT
_TLS_LOCAL_PORT = port
PROXY_PORT = port
data = _settings_load()
data["local_port"] = port
_settings_save(data)
log(f"代理转发端口已保存: {port}(下次开启代理生效)")
# 以下模块级取值需在 _data_path 定义之后执行
VERSION = _config_val("version", "999908162319")
_TLS_LOCAL_PORT = _port_val() # 本地转发器监听端口(设置 > 内置 config > 1080)
PROXY_PORT = _TLS_LOCAL_PORT
# ── PAC 数据在线更新(CDN 直连,无需认证) ─────────────
# 更新源地址取 config.json 的 pac_base_url,目录下直接放两个数据文件:
# {pac_base_url}/pac_domains.json 强制隧道域名表(version/date 字段随文件下发)
# {pac_base_url}/pac_ip_cn.json 中国 IP 段(同上)
_PAC_FILES = ("pac_domains", "pac_ip_cn")
def _pac_read_version(path):
"""读取 PAC 数据文件内的 version/date 字段。"""
try:
import json
data = json.loads(Path(path).read_text("utf-8"))
return str(data.get("version") or ""), str(data.get("date") or "")
except Exception:
return "", ""
def _pac_no_proxy_opener():
"""直连 opener:显式空代理,忽略系统/环境代理设置(CDN 无需认证)。"""
import urllib.request
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
def _pac_fetch_json(url, timeout=15, tries=2):
opener = _pac_no_proxy_opener()
import json
for i in range(tries):
try:
with opener.open(url, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
except Exception:
if i == tries - 1:
raise
def _pac_validate(name, data):
if name == "pac_domains":
return isinstance(data.get("domains"), list)
if name == "pac_ip_cn":
return isinstance(data.get("cidrs"), list)
return False
def _pac_write(name, data):
"""PAC 数据原子写入用户数据目录(tmp+replace,写不坏旧文件)。"""
import json
_USER_DIR.mkdir(parents=True, exist_ok=True)
tmp = _USER_DIR / f"{name}.json.tmp"
tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
os.replace(tmp, _USER_DIR / f"{name}.json")
# ── nodes.json 加密存储(Windows DPAPI,本机本用户可解) ─────────────
# 非 Windows(dev/测试)不加密,读写明文;读取对密文/明文都兼容。
class _DPAPI_BLOB(ctypes.Structure):
_fields_ = [("cbData", ctypes.c_uint32),
("pbData", ctypes.POINTER(ctypes.c_char))]
def _dpapi_protect(data: bytes) -> bytes:
"""DPAPI 加密 CryptProtectData(无附加熵,用户+机器绑定)。"""
crypt32 = ctypes.WinDLL("crypt32")
kernel32 = ctypes.WinDLL("kernel32")
crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.c_wchar_p,
ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p,
ctypes.c_void_p, ctypes.c_uint32,
ctypes.POINTER(_DPAPI_BLOB)]
crypt32.CryptProtectData.restype = ctypes.c_bool
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
buf = ctypes.create_string_buffer(data, len(data))
blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
blob_out = _DPAPI_BLOB()
if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
None, None, 0, ctypes.byref(blob_out)):
raise OSError("CryptProtectData 调用失败")
try:
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
finally:
kernel32.LocalFree(blob_out.pbData)
def _dpapi_unprotect(data: bytes) -> bytes:
"""DPAPI 解密 CryptUnprotectData。"""
crypt32 = ctypes.WinDLL("crypt32")
kernel32 = ctypes.WinDLL("kernel32")
crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.POINTER(ctypes.c_wchar_p),
ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p,
ctypes.c_void_p, ctypes.c_uint32,
ctypes.POINTER(_DPAPI_BLOB)]
crypt32.CryptUnprotectData.restype = ctypes.c_bool
kernel32.LocalFree.argtypes = [ctypes.c_void_p]
buf = ctypes.create_string_buffer(data, len(data))
blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
descr = ctypes.c_wchar_p()
blob_out = _DPAPI_BLOB()
if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None,
None, None, 0, ctypes.byref(blob_out)):
raise OSError("CryptUnprotectData 调用失败")
try:
return ctypes.string_at(blob_out.pbData, blob_out.cbData)
finally:
kernel32.LocalFree(blob_out.pbData)
if descr.value:
kernel32.LocalFree(descr)
def _nodes_encrypt(text: str) -> bytes:
"""加密 nodes.json 内容;非 Windows 环境明文(dev/测试)。"""
if os.name != "nt":
return text.encode("utf-8")
return _dpapi_protect(text.encode("utf-8"))
def _nodes_decrypt(blob: bytes) -> str:
"""解密 nodes.json;解密失败回退按明文解析(兼容旧文件与在线拉取写入的明文)。"""
if os.name != "nt":
return blob.decode("utf-8")
try:
return _dpapi_unprotect(blob).decode("utf-8")
except Exception:
return blob.decode("utf-8")
def _auth_save(username, password):
"""登录凭据加密保存到用户数据目录(DPAPI,本机本用户可解)。"""
import json
text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
_USER_DIR.mkdir(parents=True, exist_ok=True)
(_USER_DIR / "auth.json").write_bytes(_nodes_encrypt(text))
def _auth_load():
"""读取已保存的登录凭据;不存在或损坏返回 None。"""
try:
import json
f = _USER_DIR / "auth.json"
if not f.is_file():
return None
data = json.loads(_nodes_decrypt(f.read_bytes()))
if not (isinstance(data, dict) and data.get("username") and data.get("password")):
return None
return data
except Exception:
return None
def _nodes_sync_remote():
"""启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。"""
cred = _auth_load()
if not cred:
return # 从未登录过,等登录框处理
url = str(_config_val("nodes_url", "") or "").strip()
if not url:
return
try:
import json
data = _fetch_nodes_auth(url, cred["username"], cred["password"])
if not (isinstance(data, list) and data
and all(isinstance(n, dict) for n in data)
and str(data[0].get("domain") or "").strip()):
raise ValueError("返回的节点数据无效")
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
local = _USER_DIR / "nodes.json"
if local.is_file() and _nodes_decrypt(local.read_bytes()) == text:
log("节点配置已是最新")
return
_USER_DIR.mkdir(parents=True, exist_ok=True)
local.write_bytes(_nodes_encrypt(text))
global _NODES
_NODES = None # 使后续读取(节点菜单/开启代理)拿到新配置
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
except Exception as e:
log(f"节点配置在线更新失败,使用本地配置: {e}")
def _fetch_nodes_auth(url, user, password, timeout=10):
"""带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。"""
import json
import urllib.request
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
req = urllib.request.Request(url)
req.add_header("Authorization", "Basic " + base64.b64encode(
f"{user}:{password}".encode()).decode())
with opener.open(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _user_init_local():
"""初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。"""
_USER_DIR.mkdir(parents=True, exist_ok=True)
if _user_path("nodes.json") is None:
import json
template = [{"domain": "", "port": 443, "country": "",
"username": "", "password": "", "remark": ""}]
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(
json.dumps(template, ensure_ascii=False, indent=2) + "\n"))
log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 "
f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
def _user_init_remote(base_url=None):
"""检查并更新 PAC 数据(后台线程):
直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
节点配置的拉取走登录框(nodes_url 需 nginx 认证,见 LoginDialog)。
"""
base = (base_url or str(_config_val("pac_base_url", "") or "")).strip().rstrip("/")
if not base:
log("未配置 PAC 更新源(config.json 的 pac_base_url),使用本地数据")
else:
import json
updated = failed = 0
for name in _PAC_FILES:
try:
data = _pac_fetch_json(f"{base}/{name}.json")
if not _pac_validate(name, data):
raise ValueError(f"{name}.json 数据格式不正确")
rver = str(data.get("version") or "")
rdate = str(data.get("date") or "")
# 版本只与用户数据目录本地文件比对(回退文件仅用于读取,不参与更新判定)
local = _USER_DIR / f"{name}.json"
lver = _pac_read_version(local)[0] if local.is_file() else ""
if rver:
if rver > lver:
_pac_write(name, data)
log(f"PAC 数据已更新: {name}.json {rver} ({rdate})")
updated += 1
# 否则已是最新,跳过
else:
# 远端缺版本号:本地没有 -> 兜底下载;本地有 -> 按内容比对
new_bytes = json.dumps(data, ensure_ascii=False).encode("utf-8")
if local.is_file() and local.read_bytes() == new_bytes:
continue
_pac_write(name, data)
log(f"PAC 数据已下载: {name}.json 无版本号 ({rdate})")
updated += 1
except Exception as e:
log(f"PAC 数据 {name}.json 更新失败,使用本地副本: {e}")
failed += 1
if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file():
log("PAC 数据已是最新")
def _load_cn_ranges():
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
global _CN_RANGES, _CN_RANGES_LOADED
if _CN_RANGES_LOADED:
return _CN_RANGES
_CN_RANGES_LOADED = True
try:
import json, ipaddress
path = _user_path("pac_ip_cn.json")
if path is None:
log_red("未找到 pac_ip_cn.json,智能分流不可用(全部走隧道)")
return _CN_RANGES
data = json.loads(Path(path).read_text("utf-8"))
cidrs = data.get("cidrs") or []
ranges = []
for c in cidrs:
net = ipaddress.ip_network(str(c).strip(), strict=False)
if net.version != 4:
continue
ranges.append((int(net.network_address), int(net.broadcast_address)))
ranges.sort()
merged = []
for s, e in ranges:
if merged and s <= merged[-1][1] + 1:
if e > merged[-1][1]:
merged[-1] = (merged[-1][0], e)
else:
merged.append((s, e))
_CN_RANGES = merged
log(f"已加载中国 IP 段: {len(cidrs)} 条 CIDR,合并为 {len(merged)} 段")
except Exception as e:
log_red(f"pac_ip_cn.json 加载失败: {e}")
return _CN_RANGES
def _load_force_proxy_suffixes():
"""加载 pac_domains.json:命中这些域名后缀的连接强制走隧道(优先于 IP 判定)。"""
global _FORCE_PROXY_SUFFIXES, _FORCE_PROXY_LOADED
if _FORCE_PROXY_LOADED:
return _FORCE_PROXY_SUFFIXES
_FORCE_PROXY_LOADED = True
try:
import json
path = _user_path("pac_domains.json")
if path is None:
return _FORCE_PROXY_SUFFIXES # 可选文件,缺失不影响 IP 分流
data = json.loads(Path(path).read_text("utf-8"))
suffixes = sorted({str(d).strip().lower().lstrip("*.")
for d in (data.get("domains") or []) if str(d).strip()})
_FORCE_PROXY_SUFFIXES = suffixes
log(f"已加载强制代理域名: {len(suffixes)} 条 (pac_domains.json)")
except Exception as e:
log_red(f"pac_domains.json 加载失败: {e}")
return _FORCE_PROXY_SUFFIXES
def _is_private_int(n: int) -> bool:
"""整数形式的 IPv4 是否属于常用内网/回环段。"""
return ((n & 0xFF000000) == 0x0A000000 or
(n & 0xFFF00000) == 0xAC100000 or
(n & 0xFFFF0000) == 0xC0A80000 or
(n & 0xFF000000) == 0x7F000000 or
(n & 0xFFFF0000) == 0xA9FE0000)
@functools.lru_cache(maxsize=16384)
def _is_china_ip(host: str):
"""判断目标是否命中中国 IP 段;域名会先解析。
任一解析结果命中中国段 -> True(直连);全部不命中 -> False(走隧道);
解析失败或数据未加载 -> None(无法判定,安全起见走隧道)。
"""
import ipaddress
try:
ip = ipaddress.ip_address(host)
addresses = [ip]
except ValueError:
try:
import socket
addresses = []
for addr in socket.getaddrinfo(host, None, socket.AF_INET, socket.SOCK_STREAM):
try:
addresses.append(ipaddress.ip_address(addr[4][0]))
except ValueError:
pass
except Exception:
return None
if not addresses:
return None
ranges = _load_cn_ranges()
if not ranges:
return None
for ip in addresses:
if ip.version != 4:
continue
n = int(ip)
# 0.0.0.0 / 内网 / 回环解析结果疑似污染或局域网,不参与判定
if n == 0 or _is_private_int(n):
continue
i = bisect.bisect_right(ranges, (n, 0xFFFFFFFF))
if i > 0 and ranges[i - 1][0] <= n <= ranges[i - 1][1]:
return True
return False
@functools.lru_cache(maxsize=8192)
def _is_force_proxy(host: str) -> bool:
"""pac_domains.json 命中的域名强制走隧道(如 claude.ai:国内可连但显示拒绝页)。"""
suffixes = _load_force_proxy_suffixes()
if not suffixes:
return False
h = host.rstrip(".").lower()
return any(h == s or h.endswith("." + s) for s in suffixes)
def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
"""判断连接是否直连。判定顺序(域名表优先于 IP):
域名表命中 -> 隧道;内网 + bypass_lan -> 直连;中国 IP -> 直连,其余 -> 隧道。
仅 PAC(智能分流)模式生效,固定代理模式全部走隧道。
"""
if smart and _is_force_proxy(host):
return False
if bypass_lan and _is_private_ip(host):
return True
if smart:
return _is_china_ip(host) is True
return False
def _parse_connect_authority(first_line: str):
"""从 CONNECT 首行提取 (host, port),失败返回 None。"""
parts = first_line.split(" ")
if len(parts) < 2:
return None
authority = parts[1]
if ":" not in authority:
return None
host, _, port_s = authority.rpartition(":")
if not host or not port_s.isdigit():
return None
return host, int(port_s)
def _parse_http_absolute(first_line: str):
"""从明文 HTTP 代理首行提取 (host, port),失败返回 None。"""
parts = first_line.split(" ")
if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
return None
from urllib.parse import urlsplit
parsed = urlsplit(parts[1])
if not parsed.hostname:
return None
port = parsed.port or (443 if parsed.scheme == "https" else 80)
return parsed.hostname, port
def _force_http_close(buf: bytes) -> bytes:
"""直连明文 HTTP 时强制 Connection: close,避免 keep-alive 复用错连其他主机。"""
end = buf.find(b"\r\n\r\n")
if end == -1:
return buf
head, tail = buf[:end], buf[end:]
lines = head.split(b"\r\n")
out = [lines[0]]
conn_seen = False
for line in lines[1:]:
low = line.lower()
if low.startswith(b"connection:"):
out.append(b"Connection: close")
conn_seen = True
elif low.startswith(b"proxy-connection:"):
continue
else:
out.append(line)
if not conn_seen:
out.append(b"Connection: close")
return b"\r\n".join(out) + tail
def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False, smart=False):
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
import socket, select
client.settimeout(2.0)
buf = b""
is_http = None
is_connect = False
injector = None
direct_target = None
max_headers = 65536
try:
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:
is_http = _starts_with_method(buf[:first_crlf] + 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 ")
first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace")
if is_connect:
target = _parse_connect_authority(first_line)
else:
target = _parse_http_absolute(first_line)
if target and _decide_direct(target[0], bypass_lan, smart):
direct_target = target
except Exception as e:
log(f"[转发器] 读取首包失败: {e}")
client.close()
return
count_traffic = direct_target is None
remote = None
try:
if direct_target:
remote = socket.create_connection(direct_target, 10)
if is_connect:
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
buf = b""
else:
buf = _force_http_close(buf)
else:
remote = socket.create_connection((remote_host, remote_port), 10)
if use_tls:
remote = ctx.wrap_socket(remote, server_hostname=remote_host)
if is_http and b"\r\n\r\n" in buf:
if not is_connect:
injector = _make_request_injector()
buf = _inject_proxy_auth(buf)
if buf:
remote.sendall(buf)
if count_traffic:
_traffic_add(up=len(buf))
except Exception as e:
log(f"[转发器] 连接目标失败: {e}")
try:
client.close()
except Exception:
pass
if remote is not None:
try:
remote.close()
except Exception:
pass
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)
if count_traffic:
_traffic_add(up=len(data))
if remote in r:
data = remote.recv(65536)
if not data:
break
client.sendall(data)
if count_traffic:
_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 _ensure_forwarder():
"""确保本地转发器在运行;未运行时启动临时全隧道转发器(静默),返回是否由本次启动。"""
if _tls_forwarder:
return False
_tls_start(_endpoint_domain(), _endpoint_port(), use_tls=True,
bypass_lan=False, smart=False, quiet=True)
return True
def _release_forwarder(started_here):
"""停止由 _ensure_forwarder 启动的临时转发器;非本次启动则保持不动。"""
if started_here:
_tls_stop()
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 服务只活在进程内,程序启动时若注册表仍指向
本地转发端口或本地 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))
# ── 可点击节点标签 ─────────────────────────────────────
_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
_NL_HOVER = _NL_BASE + " text-decoration: underline;"
class NodeLabel(QLabel):
"""可点击标签:hover 显示下划线+手型光标,点击发出 clicked 信号。"""
clicked = pyqtSignal()
def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None):
super().__init__(text)
self._base = base or _NL_BASE
self._hover = hover or self._base + " text-decoration: underline;"
self.setStyleSheet(self._base)
if tooltip:
self.setToolTip(tooltip)
def enterEvent(self, e):
self.setStyleSheet(self._hover)
self.setCursor(Qt.PointingHandCursor)
super().enterEvent(e)
def leaveEvent(self, e):
self.setStyleSheet(self._base)
self.unsetCursor()
super().leaveEvent(e)
def mouseReleaseEvent(self, e):
if e.button() == Qt.LeftButton:
self.clicked.emit()
super().mouseReleaseEvent(e)
# ── 节点配置登录框 ─────────────────────────────────────
class LoginDialog(QDialog):
"""节点配置登录框:凭据参与 nodes_url 的 nginx 认证,成功后拉取并加密保存 nodes.json。"""
_done = pyqtSignal(bool, str)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("节点配置登录")
self.setWindowIcon(load_icon())
self.setFixedSize(420, 170)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
QLineEdit {{
background-color: {C_CARD};
border: 1px solid {C_BORDER};
border-radius: 6px;
padding: 6px 8px;
color: {C_TEXT};
font-size: 12px;
}}
QPushButton#btnOk {{
background-color: {C_GREEN}; color: #fff; border: none;
border-radius: 6px; padding: 4px 12px;
font-size: 12px; font-weight: bold;
}}
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }}
QPushButton#btnCancel {{
background-color: {C_CARD}; color: {C_SUBTEXT};
border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 4px 12px; font-size: 12px;
}}
""")
root = QHBoxLayout(self)
root.setSpacing(14)
root.setContentsMargins(20, 18, 20, 16)
# 左侧 logo + 品牌小字
left = QVBoxLayout()
left.setSpacing(6)
left.addStretch()
logo = QLabel()
logo.setPixmap(load_icon().pixmap(80, 80))
logo.setFixedSize(80, 80)
logo.setAlignment(Qt.AlignCenter)
left.addWidget(logo, 0, Qt.AlignHCenter)
brand = QLabel("R.R.P.P.")
brand.setStyleSheet(f"color: {C_TITLE}; font-size: 10px;")
brand.setAlignment(Qt.AlignCenter)
left.addWidget(brand, 0, Qt.AlignHCenter)
left.addStretch()
root.addLayout(left)
# 右侧表单(外层容器按内容高度垂直居中,避免被横向布局拉伸出大段留白)
form = QVBoxLayout()
form.setSpacing(6)
self.ed_user = QLineEdit()
self.ed_user.setPlaceholderText("登录名")
form.addWidget(self.ed_user)
self.ed_pass = QLineEdit()
self.ed_pass.setPlaceholderText("密码")
self.ed_pass.setEchoMode(QLineEdit.Password)
self.ed_pass.returnPressed.connect(self._submit)
form.addWidget(self.ed_pass)
# 保存过凭据时预填,重新登录只需点一下
cred = _auth_load()
if cred:
self.ed_user.setText(cred["username"])
self.ed_pass.setText(cred["password"])
self.lb_err = QLabel("")
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
self.lb_err.setFixedHeight(14)
self.lb_err.hide() # 无错误时不占空间,避免密码框下方留大片空白
form.addWidget(self.lb_err)
bottom = QHBoxLayout()
bottom.setSpacing(10)
hint = QLabel("登录以获取节点配置")
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
bottom.addWidget(hint)
bottom.addStretch()
self.btn_cancel = QPushButton("取消")
self.btn_cancel.setObjectName("btnCancel")
self.btn_cancel.clicked.connect(self.reject)
bottom.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("登录")
self.btn_ok.setObjectName("btnOk")
self.btn_ok.clicked.connect(self._submit)
bottom.addWidget(self.btn_ok)
form.addLayout(bottom)
box = QWidget()
box.setLayout(form)
root.addWidget(box, 1, Qt.AlignVCenter)
self._done.connect(self._on_done)
def _submit(self):
user = self.ed_user.text().strip()
password = self.ed_pass.text()
if not user or not password:
self.lb_err.setText("请输入用户名和密码")
self.lb_err.show()
return
self.btn_ok.setEnabled(False)
self.btn_cancel.setEnabled(False)
self.lb_err.setText("正在登录...")
self.lb_err.show()
threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
def _worker(self, user, password):
"""后台登录:Basic 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
try:
import json
url = str(_config_val("nodes_url", "") or "").strip()
if not url:
raise RuntimeError("config.json 未配置 nodes_url")
data = _fetch_nodes_auth(url, user, password)
if not (isinstance(data, list) and data
and all(isinstance(n, dict) for n in data)
and str(data[0].get("domain") or "").strip()):
raise ValueError("返回的节点数据无效")
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
_USER_DIR.mkdir(parents=True, exist_ok=True)
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text))
_auth_save(user, password) # 记住凭据,之后每次启动自动同步节点
self._done.emit(True, str(len(data)))
except Exception as e:
msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401
else f"登录失败: {e}")
self._done.emit(False, msg)
def _on_done(self, ok, info):
self.btn_ok.setEnabled(True)
self.btn_cancel.setEnabled(True)
if ok:
log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)")
self.accept()
else:
self.lb_err.setText(info)
self.lb_err.show()
# ── 设置弹窗 ─────────────────────────────────────────
class SettingsDialog(QDialog):
"""设置弹窗:代理转发端口 + 退出登录。"""
def __init__(self, parent=None, port_locked=False):
super().__init__(parent)
self.setWindowTitle("设置")
self.setWindowIcon(load_icon())
self.setFixedSize(300, 230)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
QLineEdit {{
background-color: {C_CARD};
border: 1px solid {C_BORDER};
border-radius: 6px;
padding: 5px 8px;
color: {C_TEXT};
font-size: 12px;
}}
""")
layout = QVBoxLayout(self)
layout.setSpacing(12)
layout.setContentsMargins(20, 16, 20, 16)
title = QLabel("设置")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 代理转发端口
port_row = QHBoxLayout()
port_lab = QLabel("代理转发端口")
port_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
port_row.addWidget(port_lab)
port_row.addStretch()
self.ed_port = QLineEdit(str(_TLS_LOCAL_PORT))
self.ed_port.setFixedWidth(80)
self.ed_port.setEnabled(not port_locked)
port_row.addWidget(self.ed_port)
layout.addLayout(port_row)
layout.addStretch()
# 提示语:按钮上方居中
self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效")
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
self.lb_hint.setFixedHeight(14)
self.lb_hint.setAlignment(Qt.AlignCenter)
layout.addWidget(self.lb_hint)
# 底部:保存更改 + 退出登录 并排
btns = QHBoxLayout()
btns.setSpacing(10)
btns.addStretch()
self.btn_save = QPushButton("保存更改")
self.btn_save.setStyleSheet(f"""
QPushButton {{
background-color: {C_GREEN}; color: #fff; border: none;
border-radius: 6px; padding: 5px 14px;
font-size: 12px; font-weight: bold;
}}
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }}
""")
self.btn_save.setEnabled(not port_locked)
self.btn_save.clicked.connect(self._save_settings)
btns.addWidget(self.btn_save)
self.btn_logout = QPushButton("退出登录")
self.btn_logout.setStyleSheet(f"""
QPushButton {{
background-color: {C_RED}; color: #fff; border: none;
border-radius: 6px; padding: 5px 14px;
font-size: 12px; font-weight: bold;
}}
QPushButton:hover {{ background-color: #ff8787; }}
""")
self.btn_logout.setToolTip("清除登录凭据与本地节点配置,下次启动需重新登录")
self.btn_logout.clicked.connect(self._logout)
btns.addWidget(self.btn_logout)
btns.addStretch()
layout.addLayout(btns)
def _save_settings(self):
"""保存更改:统一校验并应用全部设置项(后续新增设置项在此扩展)。"""
port = _parse_port(self.ed_port.text())
if port is None:
self.lb_hint.setText("端口应为 1-65535 的数字")
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return
_set_local_port(port)
self.ed_port.setText(str(port))
self.lb_hint.setText(f"已保存: {port},下次开启代理生效")
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
def _logout(self):
"""退出登录:删除加密凭据与本地节点配置,清空内存缓存。"""
global _NODES
for name in ("auth.json", "nodes.json"):
try:
(_USER_DIR / name).unlink(missing_ok=True)
except Exception:
pass
_NODES = None
log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录")
self.accept()
# ── 状态指示灯(小圆点) ───────────────────────────────
class StatusDot(QLabel):
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):
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)
_status_ready = pyqtSignal(object)
def __init__(self):
super().__init__()
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._status_ready.connect(self._apply_status)
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
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()
# 后台检查 PAC 数据更新(首次启动自动拉取到用户数据目录)
threading.Thread(target=_user_init_remote, daemon=True).start()
# 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效)
threading.Thread(target=_nodes_sync_remote, daemon=True).start()
# 节点未配置时延迟弹出登录框(等窗口显示后)
QTimer.singleShot(600, self._maybe_show_login)
def init_ui(self):
"""初始化主界面布局与样式。"""
self.setWindowTitle("R.R.P.P.")
self.setWindowFlags(self.windowFlags() & ~Qt.WindowContextHelpButtonHint)
self.setWindowIcon(load_icon())
self.setFixedSize(_BASE_WINDOW_WIDTH, _BASE_WINDOW_HEIGHT)
self.setStyleSheet(f"""
QWidget {{
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)
# 节点副标题(域名可点击切换:hover 下划线+手型提示,点击弹节点列表)
node_row = QHBoxLayout()
node_row.addStretch()
prefix = QLabel("节点: ")
prefix.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;")
node_row.addWidget(prefix)
self.subtitle = NodeLabel(f"{_endpoint_domain()} ({_endpoint_country()})")
self.subtitle.clicked.connect(self._show_node_menu)
node_row.addWidget(self.subtitle)
node_row.addStretch()
layout.addLayout(node_row)
# 连接方式选择区
pac_layout = QHBoxLayout()
pac_layout.setSpacing(8)
pac_layout.addStretch()
self.cb_bypass_lan = QCheckBox("不代理内网 IP")
self.cb_bypass_lan.setChecked(True) # 默认勾选:内网 IP 直连,不经过本地转发器
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
pac_layout.addWidget(self.cb_bypass_lan)
self.cb_pac = QCheckBox("PAC 规则模式")
self.cb_pac.setChecked(True) # 默认开启智能分流(域名表/中国IP判定已足够完善)
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
pac_layout.addWidget(self.cb_pac)
pac_layout.addStretch()
layout.addLayout(pac_layout)
# 分隔线
line = QFrame()
line.setFrameShape(QFrame.HLine)
line.setStyleSheet("background-color: rgba(136, 153, 170, 80); max-height: 1px;")
layout.addWidget(line)
# 系统代理状态卡片
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)
traffic_cards = QHBoxLayout()
traffic_cards.setSpacing(8)
self.traffic_up = QLabel("↑0 B/s")
self.traffic_down = QLabel("↓0 B/s")
self.traffic_total = QLabel("0 B")
for card in (self.traffic_up, self.traffic_down, self.traffic_total):
card.setStyleSheet(f"""
QLabel {{
background-color: {C_CARD};
border: 1px solid {C_BORDER};
border-radius: 8px;
color: {C_SUBTEXT};
font-size: 12px;
padding: 10px 4px;
}}
""")
card.setAlignment(Qt.AlignCenter)
traffic_cards.addWidget(card, 1)
layout.addLayout(traffic_cards)
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)
hints = QWidget()
hints_layout = QVBoxLayout(hints)
hints_layout.setContentsMargins(0, 0, 0, 0)
hints_layout.setSpacing(1)
# 第一行:左对齐警示 + 右对齐"设置"入口
top_row = QHBoxLayout()
top_row.setContentsMargins(0, 0, 0, 0)
warn_label = QLabel("请勿二次分发此软件!")
warn_label.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
top_row.addWidget(warn_label)
top_row.addStretch()
self.link_settings = NodeLabel(
"设置", tooltip="设置",
base=f"color: {C_SUBTEXT}; font-size: 11px;",
hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;")
self.link_settings.clicked.connect(self._show_settings_dialog)
top_row.addWidget(self.link_settings)
hints_layout.addLayout(top_row)
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):
"""关闭窗口时自动清理代理和隧道。"""
try:
self.quit_app()
finally:
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):
# 节点未配置:先弹出登录框,成功后继续;取消/失败则中止开启
if _endpoint_domain() == "???":
self._show_login_dialog()
if _endpoint_domain() == "???":
log_red("未登录,无法开启代理")
return
self.btn_toggle.setEnabled(False)
self.cb_pac.setEnabled(False)
self.cb_bypass_lan.setEnabled(False)
# 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全)
use_pac = self.cb_pac.isChecked()
use_bypass_lan = self.cb_bypass_lan.isChecked()
threading.Thread(target=self._start_thread, args=(use_pac, use_bypass_lan), daemon=True).start()
def _start_thread(self, use_pac, use_bypass_lan):
try:
self._start_proxy(use_pac, use_bypass_lan)
self._op_done.emit("start", True)
except Exception as e:
log(f"[错误] 开启失败: {e}")
self._op_done.emit("start", False)
def _start_proxy(self, use_pac, use_bypass_lan):
"""启动本地转发器并设置系统代理(PAC/固定两种模式),可在后台线程调用。"""
if _endpoint_domain() == "???":
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
_traffic_reset()
log("TLS 直连模式")
ep = _endpoint_domain()
log(f" 目标域名: {ep}")
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
if use_pac:
_load_cn_ranges()
_load_force_proxy_suffixes()
_tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac)
if use_pac:
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
if use_bypass_lan:
pac_content = _apply_lan_bypass(pac_content)
# 在内存中启动本地 PAC HTTP 服务,不往 EXE 目录写任何文件
pac_port = _start_pac_server(pac_content)
pac_url = f"http://127.0.0.1:{pac_port}/proxy.pac"
log(f"使用 PAC: {pac_url}")
proxy_on(pac_url=pac_url)
else:
proxy_on(bypass_lan=use_bypass_lan)
if use_bypass_lan:
log("不代理内网 IP:内网地址将直连")
self._proxy_set_by_us = True # 标记系统代理由本程序设置,退出时据此清理
log("验证代理连通性...")
import urllib.request
# 显式 ProxyHandler 强制经本地转发器;默认 opener 会套用系统/环境代理
# 设置(PAC 模式下注册表 ProxyEnable=0,urllib 认为无代理而绕开),造成假"可达"
opener = urllib.request.build_opener(
urllib.request.ProxyHandler({"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
# baidu 验证国内直连路径,google 验证境外隧道路径
test_urls = [("baidu.com", "http://www.baidu.com"), ("google.com", "http://www.google.com")]
for name, url in test_urls:
try:
opener.open(url, timeout=10)
log(f" {name} 可达")
except Exception as e:
log(f" {name} 不可达: {e}")
log("开启成功")
def on_stop(self):
"""禁用按钮并在后台线程执行关闭流程。"""
self.btn_toggle.setEnabled(False)
threading.Thread(target=self._stop_thread, daemon=True).start()
def _stop_thread(self):
"""后台关闭代理:清理系统代理、停止 TLS/PAC 服务。"""
try:
log("正在关闭代理...")
proxy_off()
self._proxy_set_by_us = False
log("已关闭")
self._op_done.emit("stop", True)
except Exception as e:
log(f"[错误] 关闭失败: {e}")
self._op_done.emit("stop", False)
# ── 设置 ──
def _show_settings_dialog(self):
"""打开设置弹窗(代理运行中锁定端口修改);退出登录后刷新节点标题。"""
SettingsDialog(self, port_locked=self._proxy_set_by_us).exec_()
self._refresh_subtitle()
# ── 节点登录 ──
def _maybe_show_login(self):
"""启动时节点未配置:隐藏主窗口只显示登录框;登录成功恢复,取消则退出。"""
if _endpoint_domain() == "???":
self.hide()
self._show_login_dialog()
if _endpoint_domain() == "???":
self.quit_app() # 未登录且无可用节点,直接退出
return
self.show()
def _show_login_dialog(self):
"""弹出登录框;成功后重载节点并刷新标题。"""
dlg = LoginDialog(self)
if dlg.exec_() == QDialog.Accepted:
_reload_nodes()
self._refresh_subtitle()
# ── 节点切换 ──
def _show_node_menu(self):
"""弹出节点切换菜单(每次打开重新读取 nodes.json,外部改动即时生效)。"""
if self._proxy_set_by_us:
log("代理运行中,请先关闭代理再切换节点")
return
nodes = _reload_nodes()
if not nodes:
QMessageBox.information(self, "提示", "nodes.json 中没有可用节点")
return
menu = QMenu(self)
menu.setStyleSheet(f"""
QMenu {{
background-color: {C_CARD};
border: 1px solid {C_BORDER};
border-radius: 8px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 18px;
border-radius: 6px;
color: {C_TEXT};
font-size: 12px;
}}
QMenu::item:selected {{
background-color: #e3e8ee;
}}
""")
cur = _selected_index()
for i, n in enumerate(nodes):
domain = n.get("domain") or "???"
country = n.get("country") or ""
remark = n.get("remark") or ""
text = f"{domain} ({country})"
if remark and remark != domain:
text += f" {remark}"
if i == cur:
text = "✓ " + text
menu.addAction(text).triggered.connect(
lambda _=False, idx=i: self._on_node_selected(idx))
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
def _on_node_selected(self, idx):
"""切换节点:更新标题。"""
if not _switch_node(idx):
return
node = _current_node()
self._refresh_subtitle()
log(f"已切换节点: {node.get('domain')} ({node.get('country')})")
def _refresh_subtitle(self):
"""刷新节点副标题文字。"""
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})")
def _update_node_label(self):
"""代理运行中时提示禁止切换节点。"""
if self._proxy_set_by_us:
self.subtitle.setToolTip("代理运行中,请先关闭代理再切换节点")
else:
self.subtitle.setToolTip("点击切换节点")
def _after_op(self, op, ok):
"""开启/关闭操作完成后刷新界面状态并恢复按钮/控件可用性。"""
self.refresh_status()
self.btn_toggle.setEnabled(True)
# 恢复模式/PAC 控件的时机:关闭完成后、或开启失败时。
# 不能靠按钮文字判断——refresh_status 是异步的,此刻按钮样式还没更新
is_off = (op == "stop") or (op == "start" and not ok)
self.cb_pac.setEnabled(is_off)
self.cb_bypass_lan.setEnabled(is_off)
self._update_node_label()
# ── 日志 ──
_early_logs = [] # 窗口初始化期间(log_text 创建前)到达的日志缓存
def _append_log(self, msg: str):
"""追加日志并自动滚动,最多保留 200 行;log_text 未创建时先缓存。"""
if not hasattr(self, "log_text"):
self._early_logs.append(msg)
return
for m in self._early_logs + [msg]:
self._append_log_line(m)
self._early_logs.clear()
def _append_log_line(self, msg: str):
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 线程读取模式选择,传给后台线程
threading.Thread(target=self._poll_status, daemon=True).start()
def _poll_status(self):
try:
px = is_proxy_on()
except Exception:
px = False
self._status_ready.emit(px)
def _apply_status(self, px_on_):
"""GUI 线程:根据轮询结果刷新状态指示灯、IE代理、流量卡片。"""
self._status_busy = False
# IE 代理状态
if px_on_:
self.px_dot.set_on()
self.px_status.setText("已开启")
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)
# 流量卡片(始终更新,未连接时显示 0)
px_on = is_proxy_on()
if px_on:
up_rate, down_rate, up_total, down_total = _traffic_rate()
else:
up_rate, down_rate, up_total, down_total = 0, 0, 0, 0
self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s")
self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s")
self.traffic_total.setText(_fmt_bytes(up_total + down_total))
def _check_update(self):
"""后台检查服务器是否有新版本。"""
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
self._latest_version = ""
self._download_url = ""
try:
import json, urllib.request, base64
domain = _endpoint_domain()
if domain == "???":
log("更新检查: 未配置节点(nodes.json 域名为空),跳过")
return
# 更新链接优先取 config.json 的 update_url,缺失时回退到域名拼接
url = _config_val("update_url", "")
if not url:
url = f"https://{domain}/latest_version.json"
log("正在检查更新...")
# 显式走本地转发器隧道,不依赖系统代理状态:默认 opener 读注册表代理设置
# (异常退出残留 127.0.0.1 代理 -> 连接被拒),不识别 PAC 的 AutoConfigURL,
# 直连服务器还可能被墙干扰 TLS 握手(SSL UNEXPECTED_EOF)
started_here = _ensure_forwarder()
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
req = urllib.request.Request(url)
data = opener.open(req, timeout=10).read().decode("utf-8-sig")
finally:
_release_forwarder(started_here)
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}")
if VERSION.startswith("9999"):
log("您当前正在使用 Lite 版")
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}")
# 同更新检查:显式走本地转发器隧道,不依赖系统代理状态
started_here = _ensure_forwarder()
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
req = urllib.request.Request(url)
with opener.open(req, timeout=120) as resp:
data = resp.read()
finally:
_release_forwarder(started_here)
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 quit_app(self):
"""只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
"""
if self._proxy_set_by_us:
try:
proxy_off()
except Exception:
pass # 清理失败也不能阻止退出,残留交给下次启动清理
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:
# 必须用 use_last_error 捕获真实错误码:直接调 GetLastError 可能被
# ctypes 内部调用重置(曾因此漏检已运行实例,导致双开互抢端口)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.restype = ctypes.c_void_p
_SINGLE_INSTANCE_MUTEX = kernel32.CreateMutexW(None, True, r"Local\RRPP_SingleInstance")
return ctypes.get_last_error() != 183 # ERROR_ALREADY_EXISTS
except Exception:
return True # 互斥体创建失败时放行,避免把用户锁在门外
def main():
"""主入口:检查管理员权限、依赖文件,然后启动 GUI。"""
_set_per_monitor_dpi_aware()
# 单实例:第二个实例直接退出(且不能执行下面的残留代理清理,
# 否则会把第一个实例正在使用的系统代理清掉)
if not _acquire_single_instance():
app = QApplication(sys.argv)
QMessageBox.information(None, "提示", "程序已在运行中。")
sys.exit(0)
# 上次异常退出可能残留指向本地转发端口的系统代理(转发器已随进程消失),
# 不清理会导致全网不通,启动时先清掉
_cleanup_stale_proxy()
# 任务栏图标:显式设置 AppUserModelID,
# 避免 Windows 依赖 exe 图标缓存推断(冷缓存下任务栏会显示空白/默认图标)
try:
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"RRPP.RoyirinRosmontisPrivateProxy")
except Exception:
pass
# 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))
# 退出必须彻底:此前点 X 后出现过进程残留(解释器收尾被残留线程/信号
# 竞态挂住),os._exit 直接终止进程——所有清理已在 quit_app 里完成
ret = app.exec_()
os._exit(ret)
if __name__ == "__main__":
main()