Compare commits

...
5 changed files with 1885 additions and 152 deletions
+3
View File
@@ -7,3 +7,6 @@ lite/.claude/
lite/backups/
lite/build/
lite/dist/
lite/RELEASE_WORKFLOW.md
*.bak
*.bak-*
+4 -4
View File
@@ -1,6 +1,6 @@
{
"version": "",
"update_url": "",
"pac_base_url": "",
"nodes_url": ""
"version": "999909181706",
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
}
+1118 -127
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,53 @@
"""第三方 RRPP Client JSON 订阅的下载、校验与展示辅助。"""
import json
import urllib.request
from urllib.parse import urlsplit, urlunsplit
def fetch_nodes(url: str, timeout=15):
"""直连读取一个第三方订阅,避免首次导入受系统代理残留影响。"""
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
request = urllib.request.Request(
url,
headers={"Accept": "application/json", "User-Agent": "RRPP-Lite"},
)
with opener.open(request, timeout=timeout) as response:
return validate_nodes(json.loads(response.read().decode("utf-8-sig")))
def validate_nodes(data):
"""校验 RRPP Client 节点数组,返回可安全写入本地缓存的副本。"""
if not isinstance(data, list) or not data:
raise ValueError("订阅应为非空的 JSON 节点数组")
result = []
for index, node in enumerate(data, 1):
if not isinstance(node, dict):
raise ValueError(f"第 {index} 项不是节点对象")
domain = str(node.get("domain") or "").strip()
method = (node.get("methods") or {}).get("http") or {}
port = method.get("port", node.get("port"))
try:
port = int(port)
except (TypeError, ValueError):
port = 0
if not domain:
raise ValueError(f"第 {index} 项缺少 domain")
if not 1 <= port <= 65535:
raise ValueError(f"第 {index} 项的 HTTP 代理端口无效")
result.append(dict(node))
return result
def mask_url(url: str) -> str:
"""用于 UI 展示,隐藏查询参数和 URL 用户信息中的敏感令牌。"""
try:
parts = urlsplit(str(url))
host = parts.hostname or ""
if parts.port:
host = f"{host}:{parts.port}"
if parts.username:
host = "***@" + host
return urlunsplit((parts.scheme, host, parts.path, "…" if parts.query else "", ""))
except Exception:
return "已隐藏订阅地址"
+707 -21
View File
@@ -2,12 +2,13 @@
import json
import threading
from datetime import datetime
from dataclasses import dataclass
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QPushButton, QVBoxLayout, QWidget,
QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
)
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
@@ -103,8 +104,9 @@ class LoginDialog(QDialog):
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
bottom.addWidget(hint)
bottom.addStretch()
self.btn_cancel = QPushButton("取消")
self.btn_cancel = QPushButton("跳过登录")
self.btn_cancel.setObjectName("btnCancel")
self.btn_cancel.setToolTip("可稍后在设置中登录,或添加第三方订阅")
self.btn_cancel.clicked.connect(self.reject)
bottom.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("登录")
@@ -291,6 +293,636 @@ class WhitelistDialog(QDialog):
self.accept()
@dataclass(frozen=True)
class FixedRouteServices:
"""域名路由编辑框所需的规则与节点服务。"""
load_icon: object
load_rules: object
available_nodes: object
validate_domain: object
save_rules: object
class FixedRouteDialog(QDialog):
"""为域名配置强制直连或指定节点代理。"""
def __init__(self, services: FixedRouteServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("域名路由")
self.setWindowIcon(services.load_icon())
self.setFixedSize(620, 530)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
QLineEdit, QComboBox {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 5px 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: 5px 7px; 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: 5px 12px; font-size: 12px; font-weight: bold; }}
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(9)
layout.setContentsMargins(18, 14, 18, 14)
title = QLabel("域名路由")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
hint = QLabel("example.com 匹配根域及子域;*.example.com 仅匹配子域")
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(16)
self.lb_err.hide()
layout.addWidget(self.lb_err)
self.list_widget = QListWidget()
self.list_widget.setToolTip("勾选启用规则;选中后可删除")
layout.addWidget(self.list_widget, 1)
domain_row = QHBoxLayout()
domain_row.setSpacing(7)
self.ed_domain = QLineEdit()
self.ed_domain.setPlaceholderText("域名,如 example.com 或 *.example.com")
self.ed_domain.returnPressed.connect(self._add_rule)
domain_row.addWidget(self.ed_domain, 1)
self.cb_action = QComboBox()
self.cb_action.addItem("强制不代理", "direct")
self.cb_action.addItem("强制节点代理", "node")
self.cb_action.currentIndexChanged.connect(self._on_action_changed)
domain_row.addWidget(self.cb_action)
self.cb_node = QComboBox()
self.cb_node.setMinimumWidth(190)
domain_row.addWidget(self.cb_node)
self.btn_add = QPushButton("添加")
self.btn_add.setObjectName("btnOk")
self.btn_add.clicked.connect(self._add_rule)
domain_row.addWidget(self.btn_add)
layout.addLayout(domain_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.addStretch()
self.btn_cancel = QPushButton("取消")
self.btn_cancel.setObjectName("btnSmall")
self.btn_cancel.clicked.connect(self.reject)
buttons.addWidget(self.btn_cancel)
self.btn_save = QPushButton("保存")
self.btn_save.setObjectName("btnOk")
self.btn_save.clicked.connect(self._save)
buttons.addWidget(self.btn_save)
layout.addLayout(buttons)
self._reload_nodes()
for rule in services.load_rules():
self._add_item(rule)
self._on_action_changed()
def _reload_nodes(self):
self.cb_node.clear()
for node in self._services.available_nodes():
self.cb_node.addItem(node["label"], node["ref"])
if not self.cb_node.count():
self.cb_node.addItem("暂无可用节点", None)
def _on_action_changed(self, *_):
self.cb_node.setEnabled(self.cb_action.currentData() == "node")
def _add_item(self, rule):
domain = str(rule.get("domain") or rule.get("value") or "").strip()
if not domain:
return
action = str(rule.get("action") or "direct")
node_label = str(rule.get("node_label") or "节点不可用")
target = "强制不代理" if action == "direct" else f"节点代理:{node_label}"
item = QListWidgetItem(f"{domain} → {target}")
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked if rule.get("enabled", True) else Qt.Unchecked)
item.setData(Qt.UserRole, {
"domain": domain,
"action": action,
"node_ref": str(rule.get("node_ref") or ""),
})
self.list_widget.addItem(item)
def _add_rule(self):
domain, error = self._services.validate_domain(self.ed_domain.text())
if error:
self.lb_err.setText(error)
self.lb_err.show()
return
action = self.cb_action.currentData()
node_ref = self.cb_node.currentData() if action == "node" else ""
if action == "node" and not node_ref:
self.lb_err.setText("请先配置至少一个可用节点")
self.lb_err.show()
return
for index in range(self.list_widget.count()):
existing = self.list_widget.item(index).data(Qt.UserRole) or {}
if str(existing.get("domain") or "").lower() == domain.lower():
self.lb_err.setText("该域名规则已存在")
self.lb_err.show()
return
self.lb_err.hide()
self._add_item({
"domain": domain,
"action": action,
"node_ref": node_ref,
"node_label": self.cb_node.currentText() if action == "node" else "",
"enabled": True,
})
self.ed_domain.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)
rule = dict(item.data(Qt.UserRole) or {})
rule["enabled"] = item.checkState() == Qt.Checked
rules.append(rule)
self._services.save_rules(rules)
self.accept()
@dataclass(frozen=True)
class JumpServices:
"""代理跳板管理所需的节点摘要与配置回调。"""
load_icon: object
load_profiles: object
available_nodes: object
add_profile: object
remove_profile: object
class JumpDialog(QDialog):
"""配置最多三跳的节点链路;设置中只保存节点稳定标识。"""
def __init__(self, services: JumpServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("代理跳板")
self.setWindowIcon(services.load_icon())
self.setFixedSize(560, 520)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; 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: 5px 7px; 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: 5px 12px; font-size: 12px; font-weight: bold; }}
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
QPushButton#btnDanger {{ background-color: {C_RED}; color: #fff; border: none;
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(8)
layout.setContentsMargins(18, 14, 18, 14)
title = QLabel("代理跳板")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
hint = QLabel("按顺序加入节点,至少 2 个、最多 3 个;最后一个节点是出口")
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
hint.setAlignment(Qt.AlignCenter)
layout.addWidget(hint)
saved_label = QLabel("已保存跳板")
saved_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
layout.addWidget(saved_label)
self.profiles = QListWidget()
self.profiles.setFixedHeight(68)
self.profiles.setToolTip("选择已保存跳板后可删除;新链路在下方配置")
layout.addWidget(self.profiles)
columns = QHBoxLayout()
columns.setSpacing(8)
left = QVBoxLayout()
left.addWidget(QLabel("当前可用节点"))
self.available = QListWidget()
self.available.setToolTip("选择节点后点击“加入链路”")
left.addWidget(self.available, 1)
columns.addLayout(left, 1)
middle = QVBoxLayout()
middle.addStretch()
self.btn_add_node = QPushButton("加入链路 →")
self.btn_add_node.setObjectName("btnSmall")
self.btn_add_node.clicked.connect(self._add_node)
middle.addWidget(self.btn_add_node)
self.btn_remove_node = QPushButton("← 移除")
self.btn_remove_node.setObjectName("btnSmall")
self.btn_remove_node.clicked.connect(self._remove_node)
middle.addWidget(self.btn_remove_node)
middle.addStretch()
columns.addLayout(middle)
right = QVBoxLayout()
right.addWidget(QLabel("新跳板链路"))
self.route = QListWidget()
self.route.setToolTip("顺序即连接顺序;可用上下按钮调整")
right.addWidget(self.route, 1)
reorder = QHBoxLayout()
self.btn_up = QPushButton("上移")
self.btn_up.setObjectName("btnSmall")
self.btn_up.clicked.connect(lambda: self._move_node(-1))
reorder.addWidget(self.btn_up)
self.btn_down = QPushButton("下移")
self.btn_down.setObjectName("btnSmall")
self.btn_down.clicked.connect(lambda: self._move_node(1))
reorder.addWidget(self.btn_down)
right.addLayout(reorder)
columns.addLayout(right, 1)
layout.addLayout(columns, 1)
self.lb_status = QLabel("")
self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
self.lb_status.setFixedHeight(16)
layout.addWidget(self.lb_status)
actions = QHBoxLayout()
self.btn_add_profile = QPushButton("添加")
self.btn_add_profile.setObjectName("btnOk")
self.btn_add_profile.clicked.connect(self._add_profile)
actions.addWidget(self.btn_add_profile)
self.btn_delete_profile = QPushButton("删除选中跳板")
self.btn_delete_profile.setObjectName("btnDanger")
self.btn_delete_profile.clicked.connect(self._delete_profile)
actions.addWidget(self.btn_delete_profile)
actions.addStretch()
self.btn_close = QPushButton("关闭")
self.btn_close.setObjectName("btnSmall")
self.btn_close.clicked.connect(self.accept)
actions.addWidget(self.btn_close)
layout.addLayout(actions)
self._reload()
def _reload(self):
self.available.clear()
for node in self._services.available_nodes():
item = QListWidgetItem(node["label"])
item.setData(Qt.UserRole, node["ref"])
self.available.addItem(item)
self.profiles.clear()
for profile in self._services.load_profiles():
state = "可用" if profile.get("available") else "节点不可用"
item = QListWidgetItem(f"{profile['name']} · {profile['route']} · {state}")
item.setData(Qt.UserRole, profile["id"])
self.profiles.addItem(item)
self.route.clear()
self.lb_status.setText("")
def _add_node(self):
item = self.available.currentItem()
if item is None:
self._status("请先选择一个节点", error=True)
return
ref = item.data(Qt.UserRole)
refs = {self.route.item(i).data(Qt.UserRole) for i in range(self.route.count())}
if ref in refs:
self._status("同一条链路不能重复使用节点", error=True)
return
if self.route.count() >= 3:
self._status("最多只能配置 3 个节点", error=True)
return
copy = QListWidgetItem(item.text())
copy.setData(Qt.UserRole, ref)
self.route.addItem(copy)
self._status(f"当前链路:{self.route.count()} 个节点")
def _remove_node(self):
item = self.route.currentItem()
if item is not None:
self.route.takeItem(self.route.row(item))
def _move_node(self, offset):
row = self.route.currentRow()
new_row = row + offset
if row < 0 or not 0 <= new_row < self.route.count():
return
item = self.route.takeItem(row)
self.route.insertItem(new_row, item)
self.route.setCurrentRow(new_row)
def _add_profile(self):
if not 2 <= self.route.count() <= 3:
self._status("请先配置 2-3 个节点的链路", error=True)
return
refs = [self.route.item(i).data(Qt.UserRole) for i in range(self.route.count())]
try:
self._services.add_profile(refs)
except Exception as error:
self._status(str(error), error=True)
return
self._reload()
self._status("跳板已添加")
def _delete_profile(self):
item = self.profiles.currentItem()
if item is None:
self._status("请先选择要删除的跳板", error=True)
return
try:
self._services.remove_profile(item.data(Qt.UserRole))
except Exception as error:
self._status(str(error), error=True)
return
self._reload()
self._status("跳板已删除")
def _status(self, message, error=False):
self.lb_status.setText(message)
color = C_RED if error else C_GREEN
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
@dataclass(frozen=True)
class ThirdPartyServices:
"""第三方订阅管理框所需的显式业务服务。"""
load_icon: object
load_subscriptions: object
add_subscription: object
refresh_subscription: object
remove_subscription: object
class ThirdPartyDialog(QDialog):
"""管理多个第三方 RRPP Client JSON 订阅。"""
_done = pyqtSignal(str, bool, str)
def __init__(self, services: ThirdPartyServices, parent=None):
super().__init__(parent)
self._services = services
self._busy = False
self.setWindowTitle("第三方订阅")
self.setWindowIcon(services.load_icon())
self.setFixedSize(520, 440)
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; }}
QListWidget {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 4px; color: {C_TEXT}; font-size: 12px; }}
QListWidget::item {{ padding: 5px 7px; 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: 5px 12px; font-size: 12px; font-weight: bold; }}
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
QPushButton#btnDanger {{ background-color: {C_RED}; color: #fff; border: none;
border-radius: 6px; padding: 5px 12px; font-size: 12px; font-weight: bold; }}
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(9)
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("仅接受 RRPP Client 的 JSON 节点数组;订阅地址和节点凭据会加密保存在本机")
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
hint.setAlignment(Qt.AlignCenter)
layout.addWidget(hint)
self.list_widget = QListWidget()
self.list_widget.setToolTip("选择一个订阅后可立即更新或删除")
layout.addWidget(self.list_widget, 1)
self.ed_name = QLineEdit()
self.ed_name.setPlaceholderText("订阅名称,例如:我的备用节点")
layout.addWidget(self.ed_name)
self.ed_url = QLineEdit()
self.ed_url.setPlaceholderText("订阅链接(http:// 或 https://)")
self.ed_url.returnPressed.connect(self._add)
layout.addWidget(self.ed_url)
self.lb_status = QLabel("")
self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
self.lb_status.setFixedHeight(16)
layout.addWidget(self.lb_status)
actions = QHBoxLayout()
self.btn_add = QPushButton("添加")
self.btn_add.setObjectName("btnOk")
self.btn_add.clicked.connect(self._add)
actions.addWidget(self.btn_add)
self.btn_refresh = QPushButton("立即更新")
self.btn_refresh.setObjectName("btnSmall")
self.btn_refresh.clicked.connect(self._refresh)
actions.addWidget(self.btn_refresh)
self.btn_delete = QPushButton("删除")
self.btn_delete.setObjectName("btnDanger")
self.btn_delete.clicked.connect(self._delete)
actions.addWidget(self.btn_delete)
actions.addStretch()
self.btn_close = QPushButton("关闭")
self.btn_close.setObjectName("btnSmall")
self.btn_close.clicked.connect(self.accept)
actions.addWidget(self.btn_close)
layout.addLayout(actions)
self._done.connect(self._on_done)
self._reload_list()
def _reload_list(self, selected_id=None):
self.list_widget.clear()
for item_data in self._services.load_subscriptions():
text = (f"{item_data['name']} · {item_data['node_count']} 个节点"
f" · {item_data.get('updated_at') or '未更新'}\n{item_data['url']}")
if item_data.get("last_error"):
text += f" · 上次更新失败:{item_data['last_error']}"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, item_data["id"])
self.list_widget.addItem(item)
if selected_id and item_data["id"] == selected_id:
self.list_widget.setCurrentItem(item)
def _set_busy(self, busy, message=""):
self._busy = busy
for button in (self.btn_add, self.btn_refresh, self.btn_delete, self.btn_close):
button.setEnabled(not busy)
self.ed_name.setEnabled(not busy)
self.ed_url.setEnabled(not busy)
if message:
self.lb_status.setText(message)
self.lb_status.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
def _add(self):
if self._busy:
return
name, url = self.ed_name.text().strip(), self.ed_url.text().strip()
if not name or not url:
self.lb_status.setText("请填写订阅名称和链接")
self.lb_status.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return
self._set_busy(True, "正在下载并校验订阅...")
threading.Thread(target=self._worker, args=("add", name, url), daemon=True).start()
def _selected_id(self):
item = self.list_widget.currentItem()
return item.data(Qt.UserRole) if item else None
def _refresh(self):
subscription_id = self._selected_id()
if not subscription_id or self._busy:
self.lb_status.setText("请先选择一个订阅")
self.lb_status.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return
self._set_busy(True, "正在更新订阅...")
threading.Thread(target=self._worker, args=("refresh", subscription_id, ""), daemon=True).start()
def _delete(self):
subscription_id = self._selected_id()
if not subscription_id or self._busy:
self.lb_status.setText("请先选择一个订阅")
self.lb_status.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
return
self._services.remove_subscription(subscription_id)
self._reload_list()
self.lb_status.setText("已删除订阅")
self.lb_status.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
def _worker(self, action, first, second):
try:
if action == "add":
self._services.add_subscription(first, second)
self._done.emit(action, True, "订阅已添加并导入")
else:
self._services.refresh_subscription(first)
self._done.emit(action, True, "订阅已更新")
except Exception as error:
self._done.emit(action, False, str(error))
def _on_done(self, action, ok, message):
self._set_busy(False)
if ok:
if action == "add":
self.ed_name.clear()
self.ed_url.clear()
self._reload_list(self._selected_id())
self.lb_status.setText(message)
color = C_GREEN if ok else C_RED
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
@dataclass(frozen=True)
class LogServices:
"""运行日志框所需的显式业务依赖。"""
load_icon: object
get_logs: object
class LogDialog(QDialog):
"""显示带时间戳的运行日志;窗口打开时会持续读取最新日志。"""
def __init__(self, services: LogServices, parent=None):
super().__init__(parent)
self._services = services
self.setWindowTitle("运行日志")
self.setWindowIcon(services.load_icon())
self.resize(760, 500)
self.setMinimumSize(560, 320)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_SUBTEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 11px; }}
QTextEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
padding: 6px 8px; color: {C_TEXT}; font-family: Consolas, "Microsoft YaHei", monospace; font-size: 11px; }}
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(8)
layout.setContentsMargins(14, 12, 14, 12)
hint = QLabel("显示当前运行期间的最近日志,认证信息不会写入日志。")
layout.addWidget(hint)
self.text = QTextEdit()
self.text.setReadOnly(True)
self.text.setLineWrapMode(QTextEdit.NoWrap)
layout.addWidget(self.text, 1)
buttons = QHBoxLayout()
buttons.setSpacing(8)
buttons.addStretch()
copy = QPushButton("复制")
copy.clicked.connect(self._copy_logs)
buttons.addWidget(copy)
export = QPushButton("导出")
export.clicked.connect(self._export_logs)
buttons.addWidget(export)
close = QPushButton("关闭")
close.clicked.connect(self.accept)
buttons.addWidget(close)
layout.addLayout(buttons)
self._last_text = None
self._refresh()
self._timer = QTimer(self)
self._timer.timeout.connect(self._refresh)
self._timer.start(500)
def _log_text(self):
text = self.text.toPlainText()
return "" if text == "暂无日志" else text
def _copy_logs(self):
QApplication.clipboard().setText(self._log_text())
def _export_logs(self):
default_name = f"rrpp-log-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
path, _ = QFileDialog.getSaveFileName(
self, "导出运行日志", default_name, "文本文件 (*.txt);;所有文件 (*.*)"
)
if not path:
return
with open(path, "w", encoding="utf-8", newline="\n") as file:
file.write(self._log_text())
def _refresh(self):
text = "\n".join(self._services.get_logs()) or "暂无日志"
if text == self._last_text:
return
scrollbar = self.text.verticalScrollBar()
follow_tail = scrollbar.value() >= scrollbar.maximum() - 4
self.text.setPlainText(text)
self._last_text = text
if follow_tail:
scrollbar.setValue(scrollbar.maximum())
@dataclass(frozen=True)
class SettingsServices:
"""设置框所需的配置与业务回调。"""
@@ -305,18 +937,23 @@ class SettingsServices:
set_update_via_proxy: object
set_skip_update_check: object
open_whitelist: object
open_jump: object
open_third_party: object
open_logs: object
has_login: object
open_login: 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.setFixedSize(300, 350)
self.setStyleSheet(f"""
QDialog {{ background-color: {C_BG}; }}
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
@@ -324,8 +961,8 @@ class SettingsDialog(QDialog):
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
""")
layout = QVBoxLayout(self)
layout.setSpacing(12)
layout.setContentsMargins(20, 16, 20, 16)
layout.setSpacing(10)
layout.setContentsMargins(20, 10, 20, 10)
title = QLabel("设置")
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
title.setAlignment(Qt.AlignCenter)
@@ -355,9 +992,9 @@ class SettingsDialog(QDialog):
layout.addStretch()
whitelist_row = QHBoxLayout()
whitelist_label = QLabel("代理白名单")
whitelist_label = QLabel("域名路由")
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
whitelist_label.setToolTip("为域名配置强制不代理,或强制经过指定节点;支持 *.example.com")
whitelist_row.addWidget(whitelist_label)
whitelist_row.addStretch()
self.btn_whitelist = QPushButton("配置")
@@ -371,6 +1008,43 @@ class SettingsDialog(QDialog):
whitelist_row.addWidget(self.btn_whitelist)
layout.addLayout(whitelist_row)
jump_row = QHBoxLayout()
jump_label = QLabel("代理跳板")
jump_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
jump_row.addWidget(jump_label)
jump_row.addStretch()
self.btn_jump = QPushButton("配置")
self.btn_jump.setCursor(Qt.PointingHandCursor)
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_jump.clicked.connect(self._open_jump)
jump_row.addWidget(self.btn_jump)
layout.addLayout(jump_row)
third_party_row = QHBoxLayout()
third_party_label = QLabel("第三方订阅")
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
third_party_row.addWidget(third_party_label)
third_party_row.addStretch()
self.btn_third_party = QPushButton("配置")
self.btn_third_party.setCursor(Qt.PointingHandCursor)
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_third_party.clicked.connect(self._open_third_party)
third_party_row.addWidget(self.btn_third_party)
layout.addLayout(third_party_row)
log_row = QHBoxLayout()
log_label = QLabel("日志")
log_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
log_label.setToolTip("查看连接域名、规则匹配、路由、错误与流量等运行信息")
log_row.addWidget(log_label)
log_row.addStretch()
self.btn_logs = QPushButton("查看")
self.btn_logs.setCursor(Qt.PointingHandCursor)
self.btn_logs.setStyleSheet(self.btn_whitelist.styleSheet())
self.btn_logs.clicked.connect(self._open_logs)
log_row.addWidget(self.btn_logs)
port_row = QHBoxLayout()
port_label = QLabel("代理转发端口")
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
@@ -401,6 +1075,7 @@ class SettingsDialog(QDialog):
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
skip_row.addWidget(self.cb_skip_update)
layout.addLayout(skip_row)
layout.addLayout(log_row)
layout.addStretch()
buttons = QHBoxLayout()
@@ -408,20 +1083,17 @@ class SettingsDialog(QDialog):
buttons.addStretch()
self.btn_save = QPushButton("保存更改")
self.btn_save.setStyleSheet(f"""
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }}
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
QPushButton:disabled {{ background-color: #eceff2; color: #a8afb8; }}
""")
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 = QPushButton("退出登录" if services.has_login() else "登录")
self.btn_logout.setStyleSheet(self.btn_save.styleSheet())
self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
self.btn_logout.clicked.connect(self._logout)
buttons.addWidget(self.btn_logout)
buttons.addStretch()
@@ -450,6 +1122,15 @@ class SettingsDialog(QDialog):
def _open_whitelist(self):
self._services.open_whitelist(self)
def _open_jump(self):
self._services.open_jump(self)
def _open_third_party(self):
self._services.open_third_party(self)
def _open_logs(self):
self._services.open_logs(self)
def _save_settings(self):
port = self._services.parse_port(self.ed_port.text())
if port is None:
@@ -464,5 +1145,10 @@ class SettingsDialog(QDialog):
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
def _logout(self):
self._services.logout()
self.accept()
if self._services.has_login():
self._services.logout()
self.btn_logout.setText("登录")
else:
self._services.open_login(self)
if self._services.has_login():
self.btn_logout.setText("退出登录")