Compare commits

..
25 Commits
Author SHA1 Message Date
RoyirinRosmontis f8eba465a9 修复: - 日志不再打印用户数据目录路径:节点配置加载与首启创建提示中的 %LOCALAPPDATA% 路径已移除 2026-08-31 16:36:44 +08:00
RoyirinRosmontis 53e9d1e31a 修复: - 检查更新不再内置回退地址:只读取 config.json 的 update_url,未配置时检查更新直接失败并记录日志 2026-08-31 16:25:48 +08:00
RoyirinRosmontis 59393b9ae9 修复: - 检查更新地址改用 config.json 的 update_url 配置,内置回退地址域名从 git.bu4.ren 更换为 git.rrrosmontis.icu 2026-08-31 16:24:15 +08:00
RoyirinRosmontis 89b5c09e95 修复: - 设置页代理转发端口输入框横向过长:宽度 72px 缩到 54px(够放下 5 位端口号) 2026-08-27 13:19:51 +08:00
RoyirinRosmontis b15beb97b4 修复: - 设置页代理转发端口输入框过大:内边距从 5px 缩到 2px,并固定为 72×22 与右侧开关同高,和其他设置项同一水平 2026-08-27 13:19:33 +08:00
RoyirinRosmontis 567d395d6f 新增功能: - 代理白名单:设置页新增第一个设置项「代理白名单+配置」,点配置打开编辑弹窗(Windows 环境变量风格列表),支持添加/删除强制直连的域名、IP、CIDR(IPv4/IPv6 均支持),每条规则带复选框可单独启用/禁用,保存后立即生效,白名单优先于域名表强制隧道与固定代理模式 2026-08-27 13:18:07 +08:00
RoyirinRosmontis c2c3bd1426 修复:
- 打包路径兼容 Nuitka:识别 __compiled__,无 sys._MEIPASS 时回退 __file__ 所在目录(onefile 解压临时目录);新增 build_nuitka.bat(PyQt5 插件、内置 config.json、输出名不变)
2026-08-26 14:56:06 +08:00
RoyirinRosmontis 2aeb327638 新增功能:
- 切换节点后把当前节点域名记录到用户设置(settings.json 的 current_node),下次启动自动继续使用上次选中的节点(按域名匹配,节点表变化时安全回退第一条)
2026-08-26 14:39:57 +08:00
RoyirinRosmontis 717b7569de 更新 server_config/nginx/nginx.conf 2026-08-26 11:32:44 +08:00
RoyirinRosmontis 2120009c8c 修复:
- 手动点击「检查更新」无视「跳过检查更新」设置:必定输出日志,发现新版本时照常弹窗询问下载(跳过设置只作用于启动时的自动检查)
2026-08-24 12:35:41 +08:00
RoyirinRosmontis 5620f0051e 修复:
- 版本号与"检查更新"间距调整为 4px
2026-08-24 12:32:30 +08:00
RoyirinRosmontis f8e8fd1acd 修复:
- 版本号与"检查更新"间距从 16px 缩到 6px(约一两个空格)
2026-08-24 12:31:35 +08:00
RoyirinRosmontis d1369d02d4 修复:
- 设置弹窗版本号与「检查更新」入口改为整体居中,中间隔 16px 小空
2026-08-24 12:30:10 +08:00
RoyirinRosmontis ebccfb4165 新增功能:
- 设置弹窗版本号左对齐,右侧新增「检查更新」入口(节点同款 hover),点击手动触发一次检查更新(后台执行,完成后提示见主界面日志)
2026-08-24 12:28:59 +08:00
RoyirinRosmontis b9a35684ac 新增功能:
- 设置弹窗标题下固定显示当前版本号(config.json 的 version)
2026-08-24 12:27:10 +08:00
RoyirinRosmontis 7e6c95f78a 修复:
- 保存更改成功提示去掉端口号显示,统一为"已保存,下次开启代理生效"
2026-08-24 12:24:31 +08:00
RoyirinRosmontis 54385d72ce 修复:
- ToggleSwitch 开关点击无反应:自绘 QCheckBox 在部分样式引擎下默认点击不触发切换,改为显式处理鼠标按下事件切换并重绘
2026-08-24 12:21:00 +08:00
RoyirinRosmontis 2652213a10 新增功能:
- 设置页新增「跳过检查更新」开关(settings.json 的 skip_update_check,默认关):开启后跳过 PAC 最新版拉取与程序版本检查且不输出相关日志;节点配置更新与客户端认证不受影响
2026-08-24 12:16:55 +08:00
RoyirinRosmontis 4afb810078 修复:
- 设置弹窗提示语移到"设置"标题正下方(保存结果/错误信息同位置显示)
2026-08-24 12:12:26 +08:00
RoyirinRosmontis 3519fbe9e3 修复:
- 设置页「使用代理更新」改为左文字 + 右侧苹果风格开关(ToggleSwitch 自绘:绿/灰胶囊+滑动圆钮,无图片资源)
2026-08-24 12:10:28 +08:00
RoyirinRosmontis a08ca154c9 新增功能:
- 设置页新增「使用代理更新」开关(用户级 settings.json 的 update_via_proxy,默认关):开启后检查更新与下载走节点隧道(CONNECT),关闭时直连仓库 releases API——直连不通的网络可开启隧道,默认直连避免节点隧道故障影响更新检查
2026-08-24 12:07:42 +08:00
RoyirinRosmontis f92c3f52e0 修复:
- 更新检查超时从 10 秒放宽到 20 秒(隧道到 Gitea 链路较慢,避免慢网被误判为失败)
2026-08-24 11:48:26 +08:00
RoyirinRosmontis 22498cb884 修复:
- 移除设置弹窗标题下方的分割线(视觉效果不佳)
2026-08-23 15:24:43 +08:00
RoyirinRosmontis 8e550cba6b 修复:
- 设置弹窗标题"设置"下方增加分割线(与主界面分隔线同款)
2026-08-23 15:08:37 +08:00
RoyirinRosmontis 863d6ccc57 新增功能:
- 检查更新改为基于 Gitea 仓库 releases API:版本取 release 的 tag_name(v0.1.0 式),下载取 exe 附件链接;语义化版本比较(v0.2.0 > v0.1.0)
- config.json 移除 update_url(latest_version.json 不再使用),默认检查地址为仓库 API;CDN 上的 latest_version.json 可下线
2026-08-23 14:45:54 +08:00
2 changed files with 526 additions and 63 deletions
+509 -45
View File
@@ -7,9 +7,10 @@ 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, pyqtSignal from PyQt5.QtCore import Qt, QTimer, QRectF, pyqtSignal
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor, QPainter
# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后, # GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后,
# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。 # Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。
@@ -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
@@ -88,7 +90,9 @@ def _load_nodes():
if not isinstance(data, list): if not isinstance(data, list):
raise ValueError("nodes.json 顶层应为节点数组") raise ValueError("nodes.json 顶层应为节点数组")
_NODES = data _NODES = data
log(f"已加载节点配置: {len(_NODES)} 个 ({path})") if not _SELECTED_RESTORED:
_restore_selected_node(_NODES) # 恢复上次使用的节点
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 导入节点配置")
@@ -97,6 +101,7 @@ def _load_nodes():
return _NODES return _NODES
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条) _SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
_SELECTED_RESTORED = False # 启动时是否已按设置恢复过上次选中的节点
def _current_node(): def _current_node():
"""当前使用的节点:默认取列表第一个;无节点时返回 None。""" """当前使用的节点:默认取列表第一个;无节点时返回 None。"""
@@ -121,8 +126,26 @@ def _switch_node(index):
log("已在当前节点") log("已在当前节点")
return False return False
_SELECTED_NODE = index _SELECTED_NODE = index
_save_current_node(nodes[index].get("domain") or "") # 记录到设置,下次启动恢复
return True return True
def _restore_selected_node(nodes):
"""首次加载时按设置里记录的 current_node(域名)恢复上次选中的节点。"""
global _SELECTED_NODE, _SELECTED_RESTORED
_SELECTED_RESTORED = True
saved = str(_settings_load().get("current_node") or "")
for i, n in enumerate(nodes):
if saved and str(n.get("domain") or "") == saved:
_SELECTED_NODE = i
return
_SELECTED_NODE = 0
def _save_current_node(domain):
"""把当前选中节点域名写入设置文件(下次启动自动继续使用)。"""
data = _settings_load()
data["current_node"] = str(domain or "")
_settings_save(data)
def _reload_nodes(): def _reload_nodes():
"""强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。""" """强制重新读取 nodes.json(菜单打开时调用,外部改动即时生效)。"""
global _NODES global _NODES
@@ -590,6 +613,115 @@ def _set_local_port(port):
_settings_save(data) _settings_save(data)
log(f"代理转发端口已保存: {port}(下次开启代理生效)") log(f"代理转发端口已保存: {port}(下次开启代理生效)")
def _update_via_proxy():
"""更新检查/下载是否走节点隧道(CONNECT);设置项,默认关=直连仓库。"""
return bool(_settings_load().get("update_via_proxy", False))
def _set_update_via_proxy(enabled):
"""写回「使用代理更新」设置(立即生效)。"""
data = _settings_load()
data["update_via_proxy"] = bool(enabled)
_settings_save(data)
log(f"更新方式已设为: {'走代理隧道' if enabled else '直连仓库'}")
def _skip_update_check():
"""是否跳过 PAC 数据与程序版本检查(设置项,默认关)。
不影响节点配置更新与客户端认证。"""
return bool(_settings_load().get("skip_update_check", False))
def _set_skip_update_check(enabled):
"""写回「跳过检查更新」设置(立即生效)。"""
data = _settings_load()
data["skip_update_check"] = bool(enabled)
_settings_save(data)
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):
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
parts = []
for p in str(v).strip().lstrip("vV").split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
return tuple(parts)
def _version_newer(latest, current):
"""latest 是否比 current 新(逐段数字比较,位数不同补 0)。"""
lt, ct = _ver_tuple(latest), _ver_tuple(current)
n = max(len(lt), len(ct))
lt += (0,) * (n - len(lt))
ct += (0,) * (n - len(ct))
return lt > ct
# 以下模块级取值需在 _data_path 定义之后执行 # 以下模块级取值需在 _data_path 定义之后执行
VERSION = _config_val("version", "999908162319") VERSION = _config_val("version", "999908162319")
_TLS_LOCAL_PORT = _port_val() # 本地转发器监听端口(设置 > 内置 config > 1080) _TLS_LOCAL_PORT = _port_val() # 本地转发器监听端口(设置 > 内置 config > 1080)
@@ -784,14 +916,16 @@ 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 需 nginx 认证,见 LoginDialog)。
""" """
if _skip_update_check():
return # 设置跳过:不拉取也不打日志
base = (base_url or str(_config_val("pac_base_url", "") or "")).strip().rstrip("/") base = (base_url or str(_config_val("pac_base_url", "") or "")).strip().rstrip("/")
if not base: if not base:
log("未配置 PAC 更新源(config.json 的 pac_base_url),使用本地数据") log("未配置 PAC 更新源(config.json 的 pac_base_url),使用本地数据")
@@ -938,11 +1072,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):
@@ -1363,6 +1541,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):
@@ -1372,7 +1708,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, 230) 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; }}
@@ -1380,7 +1716,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;
}} }}
@@ -1395,8 +1731,52 @@ class SettingsDialog(QDialog):
title.setAlignment(Qt.AlignCenter) title.setAlignment(Qt.AlignCenter)
layout.addWidget(title) layout.addWidget(title)
# 版本号 + 手动「检查更新」入口(整体居中,中间隔小空)
ver_row = QHBoxLayout()
ver_row.addStretch()
ver_lab = QLabel(f"版本号: {VERSION}")
ver_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
ver_row.addWidget(ver_lab)
ver_row.addSpacing(4)
self.link_check_update = NodeLabel(
"检查更新", tooltip="手动检查更新",
base=f"color: {C_SUBTEXT}; font-size: 11px;",
hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;")
self.link_check_update.clicked.connect(self._manual_check_update)
ver_row.addWidget(self.link_check_update)
ver_row.addStretch()
layout.addLayout(ver_row)
# 提示语:标题正下方(保存结果/错误也显示在这行)
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)
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("代理转发端口")
@@ -1404,19 +1784,34 @@ 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)
layout.addStretch() # 更新方式:左文字 + 右苹果式开关(默认关 = 直连仓库)
upd_row = QHBoxLayout()
upd_lab = QLabel("使用代理更新")
upd_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
upd_row.addWidget(upd_lab)
upd_row.addStretch()
self.cb_update_proxy = ToggleSwitch(checked=_update_via_proxy())
self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
upd_row.addWidget(self.cb_update_proxy)
layout.addLayout(upd_row)
# 提示语:按钮上方居中 # 跳过检查更新:左文字 + 右开关(默认关;不影响节点更新与认证)
self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效") skip_row = QHBoxLayout()
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;") skip_lab = QLabel("跳过检查更新")
self.lb_hint.setFixedHeight(14) skip_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
self.lb_hint.setAlignment(Qt.AlignCenter) skip_row.addWidget(skip_lab)
layout.addWidget(self.lb_hint) skip_row.addStretch()
self.cb_skip_update = ToggleSwitch(checked=_skip_update_check())
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
skip_row.addWidget(self.cb_skip_update)
layout.addLayout(skip_row)
layout.addStretch()
# 底部:保存更改 + 退出登录 并排 # 底部:保存更改 + 退出登录 并排
btns = QHBoxLayout() btns = QHBoxLayout()
@@ -1449,6 +1844,31 @@ class SettingsDialog(QDialog):
btns.addStretch() btns.addStretch()
layout.addLayout(btns) layout.addLayout(btns)
def _manual_check_update(self):
"""手动触发一次检查更新(后台线程,复用主窗口逻辑)。"""
parent = self.parent()
if not isinstance(parent, MainWindow):
return
self.lb_hint.setText("正在检查更新...")
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
try:
parent._update_done.connect(self._on_check_done)
except TypeError:
pass # 已连接过,忽略
threading.Thread(target=parent._check_update, kwargs={"manual": True}, daemon=True).start()
def _on_check_done(self):
self.lb_hint.setText("检查完成,结果见主界面日志")
self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
try:
self.parent()._update_done.disconnect(self._on_check_done)
except Exception:
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())
@@ -1457,8 +1877,10 @@ class SettingsDialog(QDialog):
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;") self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return return
_set_local_port(port) _set_local_port(port)
_set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
_set_skip_update_check(bool(self.cb_skip_update.isChecked()))
self.ed_port.setText(str(port)) self.ed_port.setText(str(port))
self.lb_hint.setText(f"已保存: {port},下次开启代理生效") self.lb_hint.setText("已保存,下次开启代理生效")
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;") self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
def _logout(self): def _logout(self):
@@ -1473,6 +1895,39 @@ class SettingsDialog(QDialog):
log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录") log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录")
self.accept() self.accept()
# ── 苹果风格开关 ─────────────────────────────────────
class ToggleSwitch(QCheckBox):
"""苹果风格开关:左关右开,绿/灰胶囊 + 滑动圆钮(自绘,无图片资源)。"""
def __init__(self, checked=False, parent=None):
super().__init__(parent)
self.setChecked(checked)
self.setFixedSize(42, 22)
self.setCursor(Qt.PointingHandCursor)
def paintEvent(self, e):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
# 胶囊背景:开=主题绿,关=浅灰
p.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4"))
p.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11)
# 白色圆钮:开在右,关在左
knob_x = 20.0 if self.isChecked() else 2.0
p.setBrush(QColor("#ffffff"))
p.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0))
p.end()
def mousePressEvent(self, e):
# 显式处理点击切换:自绘控件在部分样式引擎下默认点击不触发
if e.button() == Qt.LeftButton:
self.setChecked(not self.isChecked())
self.repaint()
e.accept()
return
super().mousePressEvent(e)
# ── 状态指示灯(小圆点) ─────────────────────────────── # ── 状态指示灯(小圆点) ───────────────────────────────
class StatusDot(QLabel): class StatusDot(QLabel):
@@ -2008,41 +2463,43 @@ class MainWindow(QWidget):
self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s") self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s")
self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s") self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s")
self.traffic_total.setText(_fmt_bytes(up_total + down_total)) self.traffic_total.setText(_fmt_bytes(up_total + down_total))
def _check_update(self): def _check_update(self, manual=False):
"""后台检查服务器是否有新版本。""" """后台检查服务器是否有新版本;manual=True 时无视「跳过检查更新」设置。"""
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取 # 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
self._latest_version = "" self._latest_version = ""
self._download_url = "" self._download_url = ""
try: try:
import json, urllib.request, base64 if _skip_update_check() and not manual:
domain = _endpoint_domain() return # 设置跳过(仅自动检查):不检查也不打日志
if domain == "???": import json, urllib.request
log("更新检查: 未配置节点(nodes.json 域名为空),跳过") # 基于 Gitea 仓库 releases API 检查更新;地址只取 config.json 的 update_url
return url = str(_config_val("update_url", "") or "").strip()
# 更新链接优先取 config.json 的 update_url,缺失时回退到域名拼接
url = _config_val("update_url", "")
if not url: if not url:
url = f"https://{domain}/latest_version.json" raise RuntimeError("config.json 未配置 update_url")
log("正在检查更新...") log("正在检查更新...")
# 显式走本地转发器隧道,不依赖系统代理状态:默认 opener 读注册表代理设置 if _update_via_proxy():
# (异常退出残留 127.0.0.1 代理 -> 连接被拒),不识别 PAC 的 AutoConfigURL, # 走节点隧道(CONNECT,设置「使用代理更新」开启时);显式代理避免残留干扰
# 直连服务器还可能被墙干扰 TLS 握手(SSL UNEXPECTED_EOF)
started_here = _ensure_forwarder() started_here = _ensure_forwarder()
try: try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler( opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}", {"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"})) "https": f"{PROXY_HOST}:{PROXY_PORT}"}))
req = urllib.request.Request(url) req = urllib.request.Request(url)
data = opener.open(req, timeout=10).read().decode("utf-8-sig") data = opener.open(req, timeout=20).read().decode("utf-8-sig")
finally: finally:
_release_forwarder(started_here) _release_forwarder(started_here)
else:
# 默认直连仓库;显式空代理避免系统/环境残留代理干扰
opener = _pac_no_proxy_opener()
req = urllib.request.Request(url)
data = opener.open(req, timeout=20).read().decode("utf-8-sig")
info = json.loads(data) info = json.loads(data)
log("客户端认证成功") # 基于 Gitea 仓库 releases API:版本取 tag_name,下载取 exe 附件链接
self._latest_version = info.get("latest_version", "") self._latest_version = str(info.get("tag_name") or "")
self._download_url = info.get("download_url", "") assets = info.get("assets") or []
log(f"当前版本: {VERSION},最新版本: {self._latest_version}") self._download_url = (assets[0].get("browser_download_url") or ""
if VERSION.startswith("9999"): if assets else "")
log("您当前正在使用 Lite 版") log(f"当前版本: {VERSION},最新版本: {self._latest_version or '未知'}")
except Exception as e: except Exception as e:
log(f"更新检查失败: {e}") log(f"更新检查失败: {e}")
self._latest_version = "" self._latest_version = ""
@@ -2053,7 +2510,7 @@ class MainWindow(QWidget):
def _on_update_done(self): def _on_update_done(self):
"""更新检查完成后恢复按钮;发现新版本时弹窗询问是否下载。""" """更新检查完成后恢复按钮;发现新版本时弹窗询问是否下载。"""
self.btn_toggle.setEnabled(True) self.btn_toggle.setEnabled(True)
if self._latest_version and self._latest_version > VERSION: if self._latest_version and _version_newer(self._latest_version, VERSION):
reply = QMessageBox.question( reply = QMessageBox.question(
self, "发现新版本", self, "发现新版本",
f"当前版本: {VERSION}\n最新版本: {self._latest_version}\n\n是否下载更新?", f"当前版本: {VERSION}\n最新版本: {self._latest_version}\n\n是否下载更新?",
@@ -2078,7 +2535,8 @@ class MainWindow(QWidget):
log(f"正在下载新版本: {url}") log(f"正在下载新版本: {url}")
log(f"保存到: {save_path}") log(f"保存到: {save_path}")
# 同更新检查:显式走本地转发器隧道,不依赖系统代理状态 # 同更新检查:按「使用代理更新」设置选择隧道或直连
if _update_via_proxy():
started_here = _ensure_forwarder() started_here = _ensure_forwarder()
try: try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler( opener = urllib.request.build_opener(urllib.request.ProxyHandler(
@@ -2090,6 +2548,12 @@ class MainWindow(QWidget):
data = resp.read() data = resp.read()
finally: finally:
_release_forwarder(started_here) _release_forwarder(started_here)
else:
opener = _pac_no_proxy_opener()
req = urllib.request.Request(url)
with opener.open(req, timeout=120) as resp:
data = resp.read()
if len(data) < 1024: if len(data) < 1024:
self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE") self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE")
+3 -4
View File
@@ -56,15 +56,14 @@ http {
} }
} }
# 443 端口:iam.aeolianchi.me HTTPS 站点 + 认证 CONNECT 代理
server { server {
listen 443 ssl; listen 443 ssl;
http2 on; http2 on;
server_name iam.aeolianchi.me; server_name XXX;
root /var/www/html; root /var/www/html;
ssl_certificate /etc/nginx/ssl/iam.aeolianchi.me/fullchain.pem; ssl_certificate /etc/nginx/ssl/XXX/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/iam.aeolianchi.me/privkey.pem; ssl_certificate_key /etc/nginx/ssl/XXX/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5; ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m; ssl_session_cache shared:SSL:10m;