新增功能:
- 节点配置登录弹窗(LoginDialog,风格与主界面一致):nodes.json 未配置时启动后自动弹出,点击开启代理时也会弹出 - 登录凭据以 Basic 认证请求 config.json 的 nodes_url(参与 nginx 认证),成功后拉取 nodes.json 并 DPAPI 加密保存到 %LOCALAPPDATA%\RRPP,自动刷新标题 - 401 显示"认证失败"、非法返回数据拒绝写入、请求期间按钮禁用防重复提交 - 移除原先无认证的 nodes 自动拉取(该接口已改为必须认证,避免每次启动 401 噪音)
This commit is contained in:
+158
-28
@@ -6,7 +6,7 @@ from pathlib import Path
|
|||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QWidget, QLabel, QPushButton,
|
QApplication, QWidget, QLabel, QPushButton,
|
||||||
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
||||||
QCheckBox, QMenu,
|
QCheckBox, QMenu, QDialog, QLineEdit,
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||||||
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
|
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
|
||||||
@@ -663,6 +663,17 @@ def _nodes_decrypt(blob: bytes) -> str:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return blob.decode("utf-8")
|
return blob.decode("utf-8")
|
||||||
|
|
||||||
|
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():
|
def _user_init_local():
|
||||||
"""初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。"""
|
"""初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。"""
|
||||||
@@ -676,10 +687,10 @@ def _user_init_local():
|
|||||||
log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 "
|
log_red(f"已创建 {_USER_DIR / 'nodes.json'}(加密存储),请配置 config.json 的 "
|
||||||
f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
|
f"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
|
||||||
|
|
||||||
def _user_init_remote(base_url=None, nodes_url=None):
|
def _user_init_remote(base_url=None):
|
||||||
"""检查并更新 PAC 数据与节点配置(后台线程):
|
"""检查并更新 PAC 数据(后台线程):
|
||||||
PAC 直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
|
直接拉取两个数据文件(内含 version/date),按版本比对后更新;失败用本地副本。
|
||||||
nodes 按内容比对(独立地址)。
|
节点配置的拉取走登录框(nodes_url 需 nginx 认证,见 LoginDialog)。
|
||||||
"""
|
"""
|
||||||
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:
|
||||||
@@ -716,29 +727,6 @@ def _user_init_remote(base_url=None, nodes_url=None):
|
|||||||
failed += 1
|
failed += 1
|
||||||
if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file():
|
if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file():
|
||||||
log("PAC 数据已是最新")
|
log("PAC 数据已是最新")
|
||||||
# 节点配置在线更新:地址独立于 PAC,按内容比对,仅变化时原子覆盖
|
|
||||||
nurl = (nodes_url or str(_config_val("nodes_url", "") or "")).strip()
|
|
||||||
if not nurl:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
data = _pac_fetch_json(nurl)
|
|
||||||
if not (isinstance(data, list) and data
|
|
||||||
and all(isinstance(n, dict) for n in data)):
|
|
||||||
raise ValueError("nodes.json 数据格式不正确")
|
|
||||||
new_bytes = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + b"\n"
|
|
||||||
local = _USER_DIR / "nodes.json"
|
|
||||||
if local.is_file() and local.read_bytes() == new_bytes:
|
|
||||||
return # 内容无变化
|
|
||||||
_USER_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = _USER_DIR / "nodes.json.tmp"
|
|
||||||
tmp.write_bytes(new_bytes)
|
|
||||||
os.replace(tmp, local)
|
|
||||||
global _NODES
|
|
||||||
_NODES = None # 使后续读取(节点菜单等)拿到新配置
|
|
||||||
log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)")
|
|
||||||
except Exception as e:
|
|
||||||
log(f"节点配置在线更新失败,使用本地配置: {e}")
|
|
||||||
|
|
||||||
def _load_cn_ranges():
|
def _load_cn_ranges():
|
||||||
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
|
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
|
||||||
@@ -1125,6 +1113,127 @@ class NodeLabel(QLabel):
|
|||||||
self.clicked.emit()
|
self.clicked.emit()
|
||||||
super().mouseReleaseEvent(e)
|
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(300, 232)
|
||||||
|
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: 7px 18px;
|
||||||
|
font-size: 13px; 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: 7px 18px; font-size: 13px;
|
||||||
|
}}
|
||||||
|
""")
|
||||||
|
|
||||||
|
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.ed_user = QLineEdit()
|
||||||
|
self.ed_user.setPlaceholderText("用户名")
|
||||||
|
layout.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)
|
||||||
|
layout.addWidget(self.ed_pass)
|
||||||
|
|
||||||
|
self.lb_err = QLabel("")
|
||||||
|
self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||||||
|
self.lb_err.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(self.lb_err)
|
||||||
|
|
||||||
|
btns = QHBoxLayout()
|
||||||
|
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._submit)
|
||||||
|
btns.addWidget(self.btn_ok)
|
||||||
|
btns.addStretch()
|
||||||
|
layout.addLayout(btns)
|
||||||
|
|
||||||
|
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("请输入用户名和密码")
|
||||||
|
return
|
||||||
|
self.btn_ok.setEnabled(False)
|
||||||
|
self.btn_cancel.setEnabled(False)
|
||||||
|
self.lb_err.setText("正在登录...")
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
|
||||||
# ── 状态指示灯(小圆点) ───────────────────────────────
|
# ── 状态指示灯(小圆点) ───────────────────────────────
|
||||||
|
|
||||||
class StatusDot(QLabel):
|
class StatusDot(QLabel):
|
||||||
@@ -1197,6 +1306,8 @@ class MainWindow(QWidget):
|
|||||||
threading.Thread(target=self._check_update, daemon=True).start()
|
threading.Thread(target=self._check_update, daemon=True).start()
|
||||||
# 后台检查 PAC 数据更新(首次启动自动拉取到用户数据目录)
|
# 后台检查 PAC 数据更新(首次启动自动拉取到用户数据目录)
|
||||||
threading.Thread(target=_user_init_remote, daemon=True).start()
|
threading.Thread(target=_user_init_remote, daemon=True).start()
|
||||||
|
# 节点未配置时延迟弹出登录框(等窗口显示后)
|
||||||
|
QTimer.singleShot(600, self._maybe_show_login)
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""初始化主界面布局与样式。"""
|
"""初始化主界面布局与样式。"""
|
||||||
@@ -1395,6 +1506,12 @@ class MainWindow(QWidget):
|
|||||||
self.on_stop()
|
self.on_stop()
|
||||||
|
|
||||||
def on_start(self):
|
def on_start(self):
|
||||||
|
# 节点未配置:先弹出登录框,成功后继续;取消/失败则中止开启
|
||||||
|
if _endpoint_domain() == "???":
|
||||||
|
self._show_login_dialog()
|
||||||
|
if _endpoint_domain() == "???":
|
||||||
|
log_red("未登录,无法开启代理")
|
||||||
|
return
|
||||||
self.btn_toggle.setEnabled(False)
|
self.btn_toggle.setEnabled(False)
|
||||||
self.cb_pac.setEnabled(False)
|
self.cb_pac.setEnabled(False)
|
||||||
self.cb_bypass_lan.setEnabled(False)
|
self.cb_bypass_lan.setEnabled(False)
|
||||||
@@ -1474,6 +1591,19 @@ class MainWindow(QWidget):
|
|||||||
self._op_done.emit("stop", False)
|
self._op_done.emit("stop", False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 节点登录 ──
|
||||||
|
def _maybe_show_login(self):
|
||||||
|
"""启动时节点未配置则弹出登录框。"""
|
||||||
|
if _endpoint_domain() == "???":
|
||||||
|
self._show_login_dialog()
|
||||||
|
|
||||||
|
def _show_login_dialog(self):
|
||||||
|
"""弹出登录框;成功后重载节点并刷新标题。"""
|
||||||
|
dlg = LoginDialog(self)
|
||||||
|
if dlg.exec_() == QDialog.Accepted:
|
||||||
|
_reload_nodes()
|
||||||
|
self._refresh_subtitle()
|
||||||
|
|
||||||
# ── 节点切换 ──
|
# ── 节点切换 ──
|
||||||
def _show_node_menu(self):
|
def _show_node_menu(self):
|
||||||
"""弹出节点切换菜单(每次打开重新读取 nodes.json,外部改动即时生效)。"""
|
"""弹出节点切换菜单(每次打开重新读取 nodes.json,外部改动即时生效)。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user