重构:模块化 RRPP Lite 并更新入口

This commit is contained in:
RoyirinRosmontis
2026-09-16 23:13:28 +08:00
parent c37d8af821
commit 02d36c72f7
35 changed files with 2286 additions and 1079 deletions
+4
View File
@@ -0,0 +1,4 @@
"""RRPP Lite 的 Qt 界面层。
核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
"""
+468
View File
@@ -0,0 +1,468 @@
"""需要业务服务的 Qt 对话框。"""
import json
import threading
from dataclasses import dataclass
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout, QWidget,
)
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
from .widgets import NodeLabel, ToggleSwitch
@dataclass(frozen=True)
class LoginServices:
"""登录框所需的显式业务依赖,避免 UI 反向导入程序入口。"""
load_icon: object
load_auth: object
get_nodes_url: object
fetch_nodes: object
user_dir: object
encrypt_nodes: object
save_auth: object
auth_error: type
log: object
class LoginDialog(QDialog):
"""使用 SSO 获取并加密保存节点配置的登录框。"""
_done = pyqtSignal(bool, str)
def __init__(self, services: LoginServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("节点配置登录")
self.setWindowIcon(services.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)
left = QVBoxLayout()
left.setSpacing(6)
left.addStretch()
logo = QLabel()
logo.setPixmap(services.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("SSO 邮箱")
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)
credentials = services.load_auth()
if credentials:
self.ed_user.setText(credentials["username"])
self.ed_pass.setText(credentials["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.btn_ok.setText("登录中")
threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
def _worker(self, user, password):
try:
url = str(self._services.get_nodes_url() or "").strip()
if not url:
raise RuntimeError("config.json 未配置 nodes_url")
data = self._services.fetch_nodes(url, user, password)
if not (isinstance(data, list) and data and all(isinstance(node, dict) for node in data)
and str(data[0].get("domain") or "").strip()):
raise ValueError("返回的节点数据无效")
text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
directory = self._services.user_dir()
directory.mkdir(parents=True, exist_ok=True)
(directory / "nodes.json").write_bytes(self._services.encrypt_nodes(text))
self._services.save_auth(user, password)
self._done.emit(True, str(len(data)))
except self._services.auth_error as error:
self._done.emit(False, str(error))
except Exception as error:
self._done.emit(False, f"登录失败: {error}")
def _on_done(self, ok, info):
self.btn_ok.setEnabled(True)
self.btn_cancel.setEnabled(True)
self.btn_ok.setText("登录")
if ok:
self._services.log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)")
self.accept()
else:
self.lb_err.setText(info)
self.lb_err.show()
@dataclass(frozen=True)
class WhitelistServices:
"""白名单编辑框所需的规则服务。"""
load_icon: object
load_rules: object
validate_rule: object
save_rules: object
class WhitelistDialog(QDialog):
"""编辑强制直连白名单;规则数据通过显式服务读写。"""
def __init__(self, services: WhitelistServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("代理白名单")
self.setWindowIcon(services.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 rule in services.load_rules():
self._add_item(rule["value"], rule.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)
middle = QHBoxLayout()
self.btn_delete = QPushButton("删除选中")
self.btn_delete.setObjectName("btnSmall")
self.btn_delete.clicked.connect(self._delete_selected)
middle.addWidget(self.btn_delete)
middle.addStretch()
layout.addLayout(middle)
buttons = QHBoxLayout()
buttons.setSpacing(10)
buttons.addStretch()
self.btn_cancel = QPushButton("取消")
self.btn_cancel.setObjectName("btnCancel")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("保存")
self.btn_ok.setObjectName("btnOk")
self.btn_ok.clicked.connect(self._save)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
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, error = self._services.validate_rule(self.ed_add.text())
if error:
self.lb_err.setText(error)
self.lb_err.show()
return
for index in range(self.list_widget.count()):
if self.list_widget.item(index).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 index in range(self.list_widget.count()):
item = self.list_widget.item(index)
value = str(item.text()).strip()
if value:
rules.append({"value": value, "enabled": item.checkState() == Qt.Checked})
self._services.save_rules(rules)
self.accept()
@dataclass(frozen=True)
class SettingsServices:
"""设置框所需的配置与业务回调。"""
load_icon: object
version: str
get_port: object
get_update_via_proxy: object
get_skip_update_check: object
parse_port: object
set_port: object
set_update_via_proxy: object
set_skip_update_check: object
open_whitelist: object
logout: object
class SettingsDialog(QDialog):
"""代理端口、更新方式、白名单与登录状态设置框。"""
def __init__(self, services: SettingsServices, parent=None, port_locked=False):
super().__init__(parent)
self._services = services
self.setWindowTitle("设置")
self.setWindowIcon(services.load_icon())
self.setFixedSize(300, 354)
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: 2px 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)
version_row = QHBoxLayout()
version_row.addStretch()
version_label = QLabel(f"版本号: {services.version}")
version_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
version_row.addWidget(version_label)
version_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)
version_row.addWidget(self.link_check_update)
version_row.addStretch()
layout.addLayout(version_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()
whitelist_row = QHBoxLayout()
whitelist_label = QLabel("代理白名单")
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
whitelist_row.addWidget(whitelist_label)
whitelist_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)
whitelist_row.addWidget(self.btn_whitelist)
layout.addLayout(whitelist_row)
port_row = QHBoxLayout()
port_label = QLabel("代理转发端口")
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
port_row.addWidget(port_label)
port_row.addStretch()
self.ed_port = QLineEdit(str(services.get_port()))
self.ed_port.setFixedSize(54, 22)
self.ed_port.setEnabled(not port_locked)
port_row.addWidget(self.ed_port)
layout.addLayout(port_row)
update_row = QHBoxLayout()
update_label = QLabel("使用代理更新")
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
update_row.addWidget(update_label)
update_row.addStretch()
self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy())
self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
update_row.addWidget(self.cb_update_proxy)
layout.addLayout(update_row)
skip_row = QHBoxLayout()
skip_label = QLabel("跳过检查更新")
skip_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
skip_row.addWidget(skip_label)
skip_row.addStretch()
self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check())
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
skip_row.addWidget(self.cb_skip_update)
layout.addLayout(skip_row)
layout.addStretch()
buttons = QHBoxLayout()
buttons.setSpacing(10)
buttons.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)
buttons.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)
buttons.addWidget(self.btn_logout)
buttons.addStretch()
layout.addLayout(buttons)
def _manual_check_update(self):
parent = self.parent()
if not (hasattr(parent, "_check_update") and hasattr(parent, "_update_done")):
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):
self._services.open_whitelist(self)
def _save_settings(self):
port = self._services.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
self._services.set_port(port)
self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
self.ed_port.setText(str(port))
self.lb_hint.setText("已保存,下次开启代理生效")
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
def _logout(self):
self._services.logout()
self.accept()
+11
View File
@@ -0,0 +1,11 @@
"""RRPP Lite 界面的共享色彩常量。"""
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"
+104
View File
@@ -0,0 +1,104 @@
"""无业务状态的可复用 Qt 控件。"""
from PyQt5.QtCore import Qt, QRectF, pyqtSignal
from PyQt5.QtGui import QColor, QPainter
from PyQt5.QtWidgets import QCheckBox, QLabel
from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE
_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
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, event):
self.setStyleSheet(self._hover)
self.setCursor(Qt.PointingHandCursor)
super().enterEvent(event)
def leaveEvent(self, event):
self.setStyleSheet(self._base)
self.unsetCursor()
super().leaveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.clicked.emit()
super().mouseReleaseEvent(event)
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, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4"))
painter.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11)
knob_x = 20.0 if self.isChecked() else 2.0
painter.setBrush(QColor("#ffffff"))
painter.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0))
painter.end()
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.setChecked(not self.isChecked())
self.repaint()
event.accept()
return
super().mousePressEvent(event)
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);
}}
""")