Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17cdb276ea | ||
|
|
f8eba465a9 | ||
|
|
53e9d1e31a | ||
|
|
59393b9ae9 | ||
|
|
89b5c09e95 | ||
|
|
b15beb97b4 | ||
|
|
567d395d6f | ||
|
|
c2c3bd1426 |
+351
-29
@@ -7,6 +7,7 @@ from PyQt5.QtWidgets import (
|
|||||||
QApplication, QWidget, QLabel, QPushButton,
|
QApplication, QWidget, QLabel, QPushButton,
|
||||||
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
||||||
QCheckBox, QMenu, QDialog, QLineEdit,
|
QCheckBox, QMenu, QDialog, QLineEdit,
|
||||||
|
QListWidget, QListWidgetItem,
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt, QTimer, QRectF, pyqtSignal
|
from PyQt5.QtCore import Qt, QTimer, QRectF, pyqtSignal
|
||||||
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor, QPainter
|
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor, QPainter
|
||||||
@@ -28,11 +29,12 @@ C_TEXT = "#333333" # 正文
|
|||||||
C_SUBTEXT = "#889099" # 次要文字
|
C_SUBTEXT = "#889099" # 次要文字
|
||||||
|
|
||||||
# ── 路径 ──────────────────────────────────────────────
|
# ── 路径 ──────────────────────────────────────────────
|
||||||
# frozen=True 表示已被 PyInstaller 打包为 EXE:
|
# 已被打包为 EXE(PyInstaller 的 sys.frozen / Nuitka 的 __compiled__):
|
||||||
# BASE_DIR -> EXE 解压出的临时目录(_MEIPASS)
|
# BASE_DIR -> 内置数据目录:PyInstaller 用 _MEIPASS,Nuitka onefile 用
|
||||||
|
# __file__ 所在解压临时目录(无 _MEIPASS 时回退)
|
||||||
# PERSIST_DIR -> EXE 所在目录,服务安装等需要持久化的操作都放这里
|
# PERSIST_DIR -> EXE 所在目录,服务安装等需要持久化的操作都放这里
|
||||||
if getattr(sys, 'frozen', False):
|
if getattr(sys, 'frozen', False) or "__compiled__" in globals():
|
||||||
BASE_DIR = Path(sys._MEIPASS)
|
BASE_DIR = Path(getattr(sys, "_MEIPASS", None) or Path(__file__).resolve().parent)
|
||||||
PERSIST_DIR = Path(sys.executable).resolve().parent
|
PERSIST_DIR = Path(sys.executable).resolve().parent
|
||||||
else:
|
else:
|
||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
@@ -90,7 +92,7 @@ def _load_nodes():
|
|||||||
_NODES = data
|
_NODES = data
|
||||||
if not _SELECTED_RESTORED:
|
if not _SELECTED_RESTORED:
|
||||||
_restore_selected_node(_NODES) # 恢复上次使用的节点
|
_restore_selected_node(_NODES) # 恢复上次使用的节点
|
||||||
log(f"已加载节点配置: {len(_NODES)} 个 ({path})")
|
log(f"已加载节点配置: {len(_NODES)} 个")
|
||||||
if data and not str(data[0].get("domain") or "").strip():
|
if data and not str(data[0].get("domain") or "").strip():
|
||||||
log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新,"
|
log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新,"
|
||||||
"或使用 others/nodes_tool 导入节点配置")
|
"或使用 others/nodes_tool 导入节点配置")
|
||||||
@@ -634,6 +636,74 @@ def _set_skip_update_check(enabled):
|
|||||||
_settings_save(data)
|
_settings_save(data)
|
||||||
log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)")
|
log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)")
|
||||||
|
|
||||||
|
# ── 代理白名单(用户可配置,强制直连,settings.json 的 whitelist 键) ─────────
|
||||||
|
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用},命中即直连(不经过代理)。
|
||||||
|
# 规则保存在内存缓存 _WHITELIST,连接建立时直接读取,不落盘回读;
|
||||||
|
# _WHITELIST_VERSION 在保存时 +1,使按 (host, version) 键控的匹配缓存失效。
|
||||||
|
|
||||||
|
_WHITELIST = None # 内存缓存:规范化后的规则列表
|
||||||
|
_WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效)
|
||||||
|
|
||||||
|
def _whitelist_rules():
|
||||||
|
"""白名单规则列表(内存缓存,首次读取 settings.json;保存时同步更新)。"""
|
||||||
|
global _WHITELIST
|
||||||
|
if _WHITELIST is None:
|
||||||
|
raw = _settings_load().get("whitelist")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raw = []
|
||||||
|
_WHITELIST = [{"value": str(r.get("value") or "").strip().lower(),
|
||||||
|
"enabled": bool(r.get("enabled", True))}
|
||||||
|
for r in raw if isinstance(r, dict) and str(r.get("value") or "").strip()]
|
||||||
|
return _WHITELIST
|
||||||
|
|
||||||
|
def _whitelist_save(rules):
|
||||||
|
"""保存白名单规则(规范化 + 去空白条目),更新内存缓存并记录日志。"""
|
||||||
|
global _WHITELIST, _WHITELIST_VERSION
|
||||||
|
clean = [{"value": _whitelist_normalize(r.get("value") or ""),
|
||||||
|
"enabled": bool(r.get("enabled", True))}
|
||||||
|
for r in rules if _whitelist_normalize(r.get("value") or "")]
|
||||||
|
data = _settings_load()
|
||||||
|
data["whitelist"] = clean
|
||||||
|
_settings_save(data)
|
||||||
|
_WHITELIST = clean
|
||||||
|
_WHITELIST_VERSION += 1
|
||||||
|
log(f"代理白名单已保存: {len(clean)} 条(立即生效)")
|
||||||
|
|
||||||
|
def _whitelist_normalize(entry: str) -> str:
|
||||||
|
"""条目规范化:去协议头/通配符/末尾点,统一小写。"""
|
||||||
|
s = str(entry).strip().lower()
|
||||||
|
if "://" in s:
|
||||||
|
s = s.split("://", 1)[1]
|
||||||
|
return s.rstrip("/").rstrip(".").lstrip("*.")
|
||||||
|
|
||||||
|
def _whitelist_validate_entry(entry: str):
|
||||||
|
"""校验并规范化白名单条目;返回 (规范值, 错误),错误非空表示非法。"""
|
||||||
|
s = _whitelist_normalize(entry)
|
||||||
|
if not s:
|
||||||
|
return "", "内容不能为空"
|
||||||
|
if any(ch.isspace() for ch in s):
|
||||||
|
return "", "不能包含空格"
|
||||||
|
import ipaddress
|
||||||
|
if "/" in s:
|
||||||
|
# CIDR(IPv4/IPv6 网段或单地址)
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(s, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return "", "IP/CIDR 格式不正确(如 10.0.0.0/8)"
|
||||||
|
elif s.count(".") == 3 and all(p.isdigit() for p in s.split(".")):
|
||||||
|
# 形如 x.x.x.x:必须是合法 IPv4,防止 300.1.2.3 这类误当域名
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(s)
|
||||||
|
except ValueError:
|
||||||
|
return "", "IP 格式不正确"
|
||||||
|
elif ":" in s:
|
||||||
|
# IPv6 字面量(域名不含冒号)
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(s)
|
||||||
|
except ValueError:
|
||||||
|
return "", "IP 格式不正确"
|
||||||
|
return s, ""
|
||||||
|
|
||||||
def _ver_tuple(v):
|
def _ver_tuple(v):
|
||||||
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
|
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
|
||||||
parts = []
|
parts = []
|
||||||
@@ -825,16 +895,40 @@ def _nodes_sync_remote():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"节点配置在线更新失败,使用本地配置: {e}")
|
log(f"节点配置在线更新失败,使用本地配置: {e}")
|
||||||
|
|
||||||
|
class _AuthError(Exception):
|
||||||
|
"""订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
|
||||||
|
|
||||||
|
def __init__(self, msg, code=0):
|
||||||
|
super().__init__(msg)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _b64url(s: str) -> str:
|
||||||
|
"""SSO 认证查询参数的 URL-safe Base64 编码(末尾 padding 省略)。"""
|
||||||
|
return base64.urlsafe_b64encode(str(s).encode("utf-8")).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
def _fetch_nodes_auth(url, user, password, timeout=10):
|
def _fetch_nodes_auth(url, user, password, timeout=10):
|
||||||
"""带 Basic 认证拉取 nodes.json(参与 nginx 认证;直连,不经过系统代理)。"""
|
"""用 SSO 账号拉取 nodes.json:user/password 按 URL-safe Base64 作为查询参数
|
||||||
|
拼接在 nodes_url 后(旧版 nginx auth_basic 已废弃);直连,不经过系统代理。
|
||||||
|
失败抛 _AuthError(服务端拒绝,str 为服务端中文原因)或 urllib 异常(网络问题)。"""
|
||||||
import json
|
import json
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||||
req = urllib.request.Request(url)
|
sep = "&" if "?" in url else "?"
|
||||||
req.add_header("Authorization", "Basic " + base64.b64encode(
|
req_url = f"{url}{sep}user={_b64url(user)}&password={_b64url(password)}"
|
||||||
f"{user}:{password}".encode()).decode())
|
try:
|
||||||
with opener.open(req, timeout=timeout) as r:
|
with opener.open(req_url, timeout=timeout) as r:
|
||||||
return json.loads(r.read().decode("utf-8"))
|
return json.loads(r.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# 401/403/429/5xx:服务端 body 是面向用户的中文原因,直接透出
|
||||||
|
body = ""
|
||||||
|
try:
|
||||||
|
body = e.read().decode("utf-8", "replace").strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise _AuthError(body or f"请求被拒绝 (HTTP {e.code})", e.code) from None
|
||||||
|
|
||||||
|
|
||||||
def _user_init_local():
|
def _user_init_local():
|
||||||
@@ -846,13 +940,13 @@ def _user_init_local():
|
|||||||
"username": "", "password": "", "remark": ""}]
|
"username": "", "password": "", "remark": ""}]
|
||||||
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(
|
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(
|
||||||
json.dumps(template, ensure_ascii=False, indent=2) + "\n"))
|
json.dumps(template, ensure_ascii=False, indent=2) + "\n"))
|
||||||
log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 "
|
log_red("已创建节点配置文件(加密存储),请配置 config.json 的 "
|
||||||
f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
|
"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
|
||||||
|
|
||||||
def _user_init_remote(base_url=None):
|
def _user_init_remote(base_url=None):
|
||||||
"""检查并更新 PAC 数据(后台线程):
|
"""检查并更新 PAC 数据(后台线程):
|
||||||
直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
|
直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
|
||||||
节点配置的拉取走登录框(nodes_url 需 nginx 认证,见 LoginDialog)。
|
节点配置的拉取走登录框(nodes_url 需 SSO 认证,见 LoginDialog)。
|
||||||
"""
|
"""
|
||||||
if _skip_update_check():
|
if _skip_update_check():
|
||||||
return # 设置跳过:不拉取也不打日志
|
return # 设置跳过:不拉取也不打日志
|
||||||
@@ -1002,11 +1096,55 @@ def _is_force_proxy(host: str) -> bool:
|
|||||||
h = host.rstrip(".").lower()
|
h = host.rstrip(".").lower()
|
||||||
return any(h == s or h.endswith("." + s) for s in suffixes)
|
return any(h == s or h.endswith("." + s) for s in suffixes)
|
||||||
|
|
||||||
|
def _resolve_host_ips(host: str):
|
||||||
|
"""解析域名的 IPv4/IPv6 地址集合(字面量 IP 返回自身);解析失败返回空集合。"""
|
||||||
|
import ipaddress
|
||||||
|
try:
|
||||||
|
return {ipaddress.ip_address(host)}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
# 双栈解析(AF_UNSPEC):IPv6 白名单条目也能命中域名解析出的 IPv6 地址
|
||||||
|
return {ipaddress.ip_address(a[4][0])
|
||||||
|
for a in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)}
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def _whitelist_entry_match(host: str, entry: str) -> bool:
|
||||||
|
"""单条白名单规则匹配:IP/CIDR 条目按解析地址比对,域名条目按后缀匹配。"""
|
||||||
|
h = host.rstrip(".").lower()
|
||||||
|
entry = entry.rstrip(".").lower()
|
||||||
|
import ipaddress
|
||||||
|
net = None
|
||||||
|
# 只有含点/冒号才按 IP 解析:纯数字串(如 8080)会被 ip_network 误解析为
|
||||||
|
# 整型地址,应视为域名条目
|
||||||
|
if "." in entry or ":" in entry:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(entry, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
net = None
|
||||||
|
if net is not None:
|
||||||
|
return any(addr in net for addr in _resolve_host_ips(h))
|
||||||
|
# 域名条目:精确匹配或子域后缀匹配(与 pac_domains 同一规则)
|
||||||
|
return h == entry or h.endswith("." + entry)
|
||||||
|
|
||||||
|
@functools.lru_cache(maxsize=8192)
|
||||||
|
def _is_whitelisted(host: str, version: int) -> bool:
|
||||||
|
"""host 是否命中已启用的白名单规则(按版本键控缓存,规则变更即失效)。"""
|
||||||
|
entries = [r["value"] for r in _whitelist_rules() if r.get("enabled")]
|
||||||
|
if not entries:
|
||||||
|
return False
|
||||||
|
return any(_whitelist_entry_match(host, e) for e in entries)
|
||||||
|
|
||||||
def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
|
def _decide_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
|
||||||
"""判断连接是否直连。判定顺序(域名表优先于 IP):
|
"""判断连接是否直连。判定顺序(白名单最优先):
|
||||||
域名表命中 -> 隧道;内网 + bypass_lan -> 直连;中国 IP -> 直连,其余 -> 隧道。
|
白名单命中 -> 直连;域名表命中 -> 隧道;内网 + bypass_lan -> 直连;
|
||||||
仅 PAC(智能分流)模式生效,固定代理模式全部走隧道。
|
中国 IP -> 直连,其余 -> 隧道。
|
||||||
|
白名单在 PAC 与固定代理模式下均生效(固定模式除白名单外全部走隧道)。
|
||||||
"""
|
"""
|
||||||
|
if _is_whitelisted(host, _WHITELIST_VERSION):
|
||||||
|
return True
|
||||||
if smart and _is_force_proxy(host):
|
if smart and _is_force_proxy(host):
|
||||||
return False
|
return False
|
||||||
if bypass_lan and _is_private_ip(host):
|
if bypass_lan and _is_private_ip(host):
|
||||||
@@ -1283,7 +1421,8 @@ class NodeLabel(QLabel):
|
|||||||
# ── 节点配置登录框 ─────────────────────────────────────
|
# ── 节点配置登录框 ─────────────────────────────────────
|
||||||
|
|
||||||
class LoginDialog(QDialog):
|
class LoginDialog(QDialog):
|
||||||
"""节点配置登录框:凭据参与 nodes_url 的 nginx 认证,成功后拉取并加密保存 nodes.json。"""
|
"""节点配置登录框:SSO 账号(邮箱+密码,base64url 查询参数)认证 nodes_url,
|
||||||
|
成功后拉取并加密保存 nodes.json。"""
|
||||||
_done = pyqtSignal(bool, str)
|
_done = pyqtSignal(bool, str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
@@ -1340,7 +1479,7 @@ class LoginDialog(QDialog):
|
|||||||
form = QVBoxLayout()
|
form = QVBoxLayout()
|
||||||
form.setSpacing(6)
|
form.setSpacing(6)
|
||||||
self.ed_user = QLineEdit()
|
self.ed_user = QLineEdit()
|
||||||
self.ed_user.setPlaceholderText("登录名")
|
self.ed_user.setPlaceholderText("SSO 邮箱")
|
||||||
form.addWidget(self.ed_user)
|
form.addWidget(self.ed_user)
|
||||||
|
|
||||||
self.ed_pass = QLineEdit()
|
self.ed_pass = QLineEdit()
|
||||||
@@ -1395,7 +1534,7 @@ class LoginDialog(QDialog):
|
|||||||
threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
|
threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
|
||||||
|
|
||||||
def _worker(self, user, password):
|
def _worker(self, user, password):
|
||||||
"""后台登录:Basic 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
|
"""后台登录:SSO 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
url = str(_config_val("nodes_url", "") or "").strip()
|
url = str(_config_val("nodes_url", "") or "").strip()
|
||||||
@@ -1409,12 +1548,13 @@ class LoginDialog(QDialog):
|
|||||||
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
|
||||||
_USER_DIR.mkdir(parents=True, exist_ok=True)
|
_USER_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text))
|
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text))
|
||||||
_auth_save(user, password) # 记住凭据,之后每次启动自动同步节点
|
_auth_save(user, password) # 记住 SSO 凭据,之后每次启动自动同步节点
|
||||||
self._done.emit(True, str(len(data)))
|
self._done.emit(True, str(len(data)))
|
||||||
|
except _AuthError as e:
|
||||||
|
# 服务端拒绝:直接展示其中文原因(认证失败/未开通/限流等)
|
||||||
|
self._done.emit(False, str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
msg = ("认证失败,请检查用户名/密码" if getattr(e, "code", None) == 401
|
self._done.emit(False, f"登录失败: {e}")
|
||||||
else f"登录失败: {e}")
|
|
||||||
self._done.emit(False, msg)
|
|
||||||
|
|
||||||
def _on_done(self, ok, info):
|
def _on_done(self, ok, info):
|
||||||
self.btn_ok.setEnabled(True)
|
self.btn_ok.setEnabled(True)
|
||||||
@@ -1427,6 +1567,164 @@ class LoginDialog(QDialog):
|
|||||||
self.lb_err.setText(info)
|
self.lb_err.setText(info)
|
||||||
self.lb_err.show()
|
self.lb_err.show()
|
||||||
|
|
||||||
|
# ── 代理白名单弹窗 ─────────────────────────────────────
|
||||||
|
|
||||||
|
class WhitelistDialog(QDialog):
|
||||||
|
"""代理白名单编辑弹窗:可编辑列表(Windows 环境变量风格),
|
||||||
|
每条规则带复选框启用/禁用,支持添加/删除,保存后立即生效。"""
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("代理白名单")
|
||||||
|
self.setWindowIcon(load_icon())
|
||||||
|
self.setFixedSize(380, 400)
|
||||||
|
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;
|
||||||
|
}}
|
||||||
|
QListWidget {{
|
||||||
|
background-color: {C_CARD};
|
||||||
|
border: 1px solid {C_BORDER};
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
color: {C_TEXT};
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
QListWidget::item {{ padding: 3px 6px; border-radius: 4px; }}
|
||||||
|
QListWidget::item:selected {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||||
|
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#btnCancel {{
|
||||||
|
background-color: {C_CARD}; color: {C_SUBTEXT};
|
||||||
|
border: 1px solid {C_BORDER}; border-radius: 6px;
|
||||||
|
padding: 4px 12px; font-size: 12px;
|
||||||
|
}}
|
||||||
|
QPushButton#btnSmall {{
|
||||||
|
background-color: {C_CARD}; color: {C_SUBTEXT};
|
||||||
|
border: 1px solid {C_BORDER}; border-radius: 6px;
|
||||||
|
padding: 3px 10px; font-size: 11px;
|
||||||
|
}}
|
||||||
|
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(10)
|
||||||
|
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)
|
||||||
|
|
||||||
|
hint = QLabel("命中以下规则的连接将强制直连(不经过代理)")
|
||||||
|
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
|
||||||
|
hint.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(hint)
|
||||||
|
|
||||||
|
self.lb_err = QLabel("")
|
||||||
|
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||||||
|
self.lb_err.setFixedHeight(14)
|
||||||
|
self.lb_err.hide() # 无错误时不占空间
|
||||||
|
layout.addWidget(self.lb_err)
|
||||||
|
|
||||||
|
# 规则列表:每条前面带复选框(勾选启用 / 取消禁用)
|
||||||
|
self.list_widget = QListWidget()
|
||||||
|
self.list_widget.setToolTip("勾选启用规则;可多选后删除")
|
||||||
|
for r in _whitelist_rules():
|
||||||
|
self._add_item(r["value"], r.get("enabled", True))
|
||||||
|
layout.addWidget(self.list_widget, 1)
|
||||||
|
|
||||||
|
# 添加行:输入框 + 添加按钮
|
||||||
|
add_row = QHBoxLayout()
|
||||||
|
add_row.setSpacing(8)
|
||||||
|
self.ed_add = QLineEdit()
|
||||||
|
self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com")
|
||||||
|
self.ed_add.returnPressed.connect(self._add_rule)
|
||||||
|
add_row.addWidget(self.ed_add, 1)
|
||||||
|
self.btn_add = QPushButton("添加")
|
||||||
|
self.btn_add.setObjectName("btnOk")
|
||||||
|
self.btn_add.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {C_GREEN}; color: #fff; border: none;
|
||||||
|
border-radius: 6px; padding: 6px 14px;
|
||||||
|
font-size: 12px; font-weight: bold;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background-color: #5ed978; }}
|
||||||
|
""")
|
||||||
|
self.btn_add.clicked.connect(self._add_rule)
|
||||||
|
add_row.addWidget(self.btn_add)
|
||||||
|
layout.addLayout(add_row)
|
||||||
|
|
||||||
|
# 删除选中 + 底部按钮
|
||||||
|
mid_row = QHBoxLayout()
|
||||||
|
self.btn_delete = QPushButton("删除选中")
|
||||||
|
self.btn_delete.setObjectName("btnSmall")
|
||||||
|
self.btn_delete.clicked.connect(self._delete_selected)
|
||||||
|
mid_row.addWidget(self.btn_delete)
|
||||||
|
mid_row.addStretch()
|
||||||
|
layout.addLayout(mid_row)
|
||||||
|
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
btns.setSpacing(10)
|
||||||
|
btns.addStretch()
|
||||||
|
self.btn_cancel = QPushButton("取消")
|
||||||
|
self.btn_cancel.setObjectName("btnCancel")
|
||||||
|
self.btn_cancel.clicked.connect(self.reject)
|
||||||
|
btns.addWidget(self.btn_cancel)
|
||||||
|
self.btn_ok = QPushButton("保存")
|
||||||
|
self.btn_ok.setObjectName("btnOk")
|
||||||
|
self.btn_ok.clicked.connect(self._save)
|
||||||
|
btns.addWidget(self.btn_ok)
|
||||||
|
layout.addLayout(btns)
|
||||||
|
|
||||||
|
def _add_item(self, value, enabled):
|
||||||
|
item = QListWidgetItem(value)
|
||||||
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.Checked if enabled else Qt.Unchecked)
|
||||||
|
self.list_widget.addItem(item)
|
||||||
|
|
||||||
|
def _add_rule(self):
|
||||||
|
value, err = _whitelist_validate_entry(self.ed_add.text())
|
||||||
|
if err:
|
||||||
|
self.lb_err.setText(err)
|
||||||
|
self.lb_err.show()
|
||||||
|
return
|
||||||
|
# 去重(不区分大小写)
|
||||||
|
for i in range(self.list_widget.count()):
|
||||||
|
if self.list_widget.item(i).text().lower() == value.lower():
|
||||||
|
self.lb_err.setText("该规则已存在")
|
||||||
|
self.lb_err.show()
|
||||||
|
return
|
||||||
|
self.lb_err.hide()
|
||||||
|
self._add_item(value, True)
|
||||||
|
self.ed_add.clear()
|
||||||
|
|
||||||
|
def _delete_selected(self):
|
||||||
|
for item in self.list_widget.selectedItems():
|
||||||
|
self.list_widget.takeItem(self.list_widget.row(item))
|
||||||
|
|
||||||
|
def _save(self):
|
||||||
|
"""收集列表内容保存(未点保存前所有改动均可取消)。"""
|
||||||
|
rules = []
|
||||||
|
for i in range(self.list_widget.count()):
|
||||||
|
item = self.list_widget.item(i)
|
||||||
|
value = str(item.text()).strip()
|
||||||
|
if value:
|
||||||
|
rules.append({"value": value, "enabled": item.checkState() == Qt.Checked})
|
||||||
|
_whitelist_save(rules)
|
||||||
|
self.accept()
|
||||||
|
|
||||||
# ── 设置弹窗 ─────────────────────────────────────────
|
# ── 设置弹窗 ─────────────────────────────────────────
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
class SettingsDialog(QDialog):
|
||||||
@@ -1436,7 +1734,7 @@ class SettingsDialog(QDialog):
|
|||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setWindowTitle("设置")
|
self.setWindowTitle("设置")
|
||||||
self.setWindowIcon(load_icon())
|
self.setWindowIcon(load_icon())
|
||||||
self.setFixedSize(300, 316)
|
self.setFixedSize(300, 354)
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(f"""
|
||||||
QDialog {{ background-color: {C_BG}; }}
|
QDialog {{ background-color: {C_BG}; }}
|
||||||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||||||
@@ -1444,7 +1742,7 @@ class SettingsDialog(QDialog):
|
|||||||
background-color: {C_CARD};
|
background-color: {C_CARD};
|
||||||
border: 1px solid {C_BORDER};
|
border: 1px solid {C_BORDER};
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 5px 8px;
|
padding: 2px 8px;
|
||||||
color: {C_TEXT};
|
color: {C_TEXT};
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}}
|
}}
|
||||||
@@ -1484,6 +1782,27 @@ class SettingsDialog(QDialog):
|
|||||||
|
|
||||||
layout.addStretch() # 设置项在空白部分纵向居中(上下均分)
|
layout.addStretch() # 设置项在空白部分纵向居中(上下均分)
|
||||||
|
|
||||||
|
# 代理白名单:第一个设置项,点「配置」打开编辑弹窗
|
||||||
|
wl_row = QHBoxLayout()
|
||||||
|
wl_lab = QLabel("代理白名单")
|
||||||
|
wl_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
wl_lab.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
|
||||||
|
wl_row.addWidget(wl_lab)
|
||||||
|
wl_row.addStretch()
|
||||||
|
self.btn_whitelist = QPushButton("配置")
|
||||||
|
self.btn_whitelist.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.btn_whitelist.setStyleSheet(f"""
|
||||||
|
QPushButton {{
|
||||||
|
background-color: {C_CARD}; color: {C_SUBTEXT};
|
||||||
|
border: 1px solid {C_BORDER}; border-radius: 6px;
|
||||||
|
padding: 2px 10px; font-size: 11px;
|
||||||
|
}}
|
||||||
|
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||||
|
""")
|
||||||
|
self.btn_whitelist.clicked.connect(self._open_whitelist)
|
||||||
|
wl_row.addWidget(self.btn_whitelist)
|
||||||
|
layout.addLayout(wl_row)
|
||||||
|
|
||||||
# 代理转发端口
|
# 代理转发端口
|
||||||
port_row = QHBoxLayout()
|
port_row = QHBoxLayout()
|
||||||
port_lab = QLabel("代理转发端口")
|
port_lab = QLabel("代理转发端口")
|
||||||
@@ -1491,7 +1810,7 @@ class SettingsDialog(QDialog):
|
|||||||
port_row.addWidget(port_lab)
|
port_row.addWidget(port_lab)
|
||||||
port_row.addStretch()
|
port_row.addStretch()
|
||||||
self.ed_port = QLineEdit(str(_TLS_LOCAL_PORT))
|
self.ed_port = QLineEdit(str(_TLS_LOCAL_PORT))
|
||||||
self.ed_port.setFixedWidth(80)
|
self.ed_port.setFixedSize(54, 22) # 与右侧开关同高,和其他设置项同一水平
|
||||||
self.ed_port.setEnabled(not port_locked)
|
self.ed_port.setEnabled(not port_locked)
|
||||||
port_row.addWidget(self.ed_port)
|
port_row.addWidget(self.ed_port)
|
||||||
layout.addLayout(port_row)
|
layout.addLayout(port_row)
|
||||||
@@ -1572,6 +1891,10 @@ class SettingsDialog(QDialog):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _open_whitelist(self):
|
||||||
|
"""打开代理白名单编辑弹窗(保存后立即生效,无需重启代理)。"""
|
||||||
|
WhitelistDialog(self).exec_()
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
"""保存更改:统一校验并应用全部设置项(后续新增设置项在此扩展)。"""
|
"""保存更改:统一校验并应用全部设置项(后续新增设置项在此扩展)。"""
|
||||||
port = _parse_port(self.ed_port.text())
|
port = _parse_port(self.ed_port.text())
|
||||||
@@ -2175,11 +2498,10 @@ class MainWindow(QWidget):
|
|||||||
if _skip_update_check() and not manual:
|
if _skip_update_check() and not manual:
|
||||||
return # 设置跳过(仅自动检查):不检查也不打日志
|
return # 设置跳过(仅自动检查):不检查也不打日志
|
||||||
import json, urllib.request
|
import json, urllib.request
|
||||||
# 基于 Gitea 仓库 releases API 检查更新;config.json 无 update_url 时用默认地址
|
# 基于 Gitea 仓库 releases API 检查更新;地址只取 config.json 的 update_url
|
||||||
url = str(_config_val("update_url", "") or "").strip()
|
url = str(_config_val("update_url", "") or "").strip()
|
||||||
if not url:
|
if not url:
|
||||||
url = ("https://git.bu4.ren/api/v1/repos/RoyirinRosmontis/"
|
raise RuntimeError("config.json 未配置 update_url")
|
||||||
"Private-Proxy/releases/latest")
|
|
||||||
log("正在检查更新...")
|
log("正在检查更新...")
|
||||||
if _update_via_proxy():
|
if _update_via_proxy():
|
||||||
# 走节点隧道(CONNECT,设置「使用代理更新」开启时);显式代理避免残留干扰
|
# 走节点隧道(CONNECT,设置「使用代理更新」开启时);显式代理避免残留干扰
|
||||||
|
|||||||
Reference in New Issue
Block a user