Compare commits

..
4 changed files with 520 additions and 75 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"version": "", "version": "999909171702",
"update_url": "", "pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
"pac_base_url": "", "nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
"nodes_url": "" "update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
} }
+263 -65
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid
from pathlib import Path from pathlib import Path
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
@@ -14,7 +14,7 @@ from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT from rrpp_ui.theme import C_TITLE, C_GREEN, C_YELLOW, C_RED, C_BG, C_CARD, C_BORDER, C_TEXT, C_SUBTEXT
from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch from rrpp_ui.widgets import NodeLabel, StatusDot, ToggleSwitch
from rrpp_ui.dialogs import ( from rrpp_ui.dialogs import (
LoginDialog, LoginServices, SettingsDialog, SettingsServices, LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices,
WhitelistDialog, WhitelistServices, WhitelistDialog, WhitelistServices,
) )
from rrpp_core.routing_primitives import ( from rrpp_core.routing_primitives import (
@@ -45,6 +45,10 @@ from rrpp_core.node_api import (
b64url as _b64url, b64url as _b64url,
fetch_nodes_auth as _fetch_nodes_auth, fetch_nodes_auth as _fetch_nodes_auth,
) )
from rrpp_core.third_party_subscriptions import (
fetch_nodes as _fetch_third_party_nodes,
mask_url as _mask_subscription_url,
)
from rrpp_core.network_primitives import ( from rrpp_core.network_primitives import (
is_private_host as _is_private_host, is_private_host as _is_private_host,
is_private_ipv4_int as _is_private_int, is_private_ipv4_int as _is_private_int,
@@ -110,39 +114,203 @@ def _load_config():
def _config_val(key, default=""): def _config_val(key, default=""):
return _load_config().get(key, default) return _load_config().get(key, default)
# ── 节点配置(nodes.json)──────────────────────────── # ── 节点配置(官方 nodes.json + 第三方订阅)────────────────
# 节点列表,当前固定取第一个(后续可扩展选择) # 官方节点和第三方节点分别保存;加载时才合并,避免登录更新覆盖第三方缓存。
_NODES = None _NODES = None
_THIRD_PARTY_FILE = "third_party_subscriptions.json"
_THIRD_PARTY_LOCK = threading.RLock()
def _third_party_path():
return _USER_DIR / _THIRD_PARTY_FILE
def _load_third_party_subscriptions():
"""读取本机 DPAPI 加密的第三方订阅与最近一次成功缓存。"""
with _THIRD_PARTY_LOCK:
try:
path = _third_party_path()
if not path.is_file():
return []
data = json.loads(_nodes_decrypt(path.read_bytes()))
records = data.get("subscriptions", []) if isinstance(data, dict) else []
return [record for record in records if isinstance(record, dict) and record.get("id")]
except Exception as error:
log_red(f"第三方订阅配置读取失败: {error}")
return []
def _save_third_party_subscriptions(records):
"""原子保存加密订阅配置;URL 与节点认证信息不进入明文 settings.json。"""
with _THIRD_PARTY_LOCK:
_USER_DIR.mkdir(parents=True, exist_ok=True)
text = json.dumps({"version": 1, "subscriptions": records}, ensure_ascii=False, indent=2) + "\n"
path = _third_party_path()
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(_nodes_encrypt(text))
os.replace(temporary, path)
def _subscription_nodes(records):
result = []
for record in records:
source_id = str(record.get("id") or "")
source_name = str(record.get("name") or "第三方订阅")
nodes = record.get("nodes") or []
if not isinstance(nodes, list):
continue
for node in nodes:
if not isinstance(node, dict) or not str(node.get("domain") or "").strip():
continue
item = dict(node)
item["_rrpp_source_id"] = source_id
item["_rrpp_source_name"] = source_name
item["_rrpp_source_kind"] = "third_party"
result.append(item)
return result
def _tag_official_nodes(nodes):
result = []
for node in nodes:
if not isinstance(node, dict) or not str(node.get("domain") or "").strip():
continue
item = dict(node)
item["_rrpp_source_id"] = "official"
item["_rrpp_source_name"] = "官方订阅"
item["_rrpp_source_kind"] = "official"
result.append(item)
return result
def _node_reference(node):
"""选中节点的稳定标识,避免不同订阅的同域名互相覆盖。"""
method = (node.get("methods") or {}).get("http") or {}
port = method.get("port", node.get("port", ""))
return "|".join((
str(node.get("_rrpp_source_id") or "official"),
str(node.get("domain") or ""), str(port or ""),
str(node.get("country") or ""), str(node.get("remark") or ""),
))
def _load_nodes(): def _load_nodes():
"""加载 nodes.json 节点列表(用户数据目录优先);失败或缺失时返回空列表。""" """加载并合并官方节点与每个第三方订阅的上次成功缓存。"""
global _NODES global _NODES
if _NODES is not None: if _NODES is not None:
return _NODES return _NODES
_NODES = [] official = []
try: try:
import json
path = _user_path("nodes.json") path = _user_path("nodes.json")
if path is None: if path is not None:
log_red("nodes.json 缺失,无法加载节点配置") data = json.loads(_nodes_decrypt(Path(path).read_bytes()))
return _NODES if not isinstance(data, list):
data = json.loads(_nodes_decrypt(Path(path).read_bytes())) raise ValueError("nodes.json 顶层应为节点数组")
if not isinstance(data, list): official = _tag_official_nodes(data)
raise ValueError("nodes.json 顶层应为节点数组") except Exception as error:
_NODES = data log_red(f"官方节点配置读取失败: {error}")
if not _SELECTED_RESTORED: third_party = _subscription_nodes(_load_third_party_subscriptions())
_restore_selected_node(_NODES) # 恢复上次使用的节点 _NODES = official + third_party
log(f"已加载节点配置: {len(_NODES)} 个") if not _SELECTED_RESTORED:
if data and not str(data[0].get("domain") or "").strip(): _restore_selected_node(_NODES)
log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新," if _NODES:
"或使用 others/nodes_tool 导入节点配置") log(f"已加载节点配置: 官方 {len(official)} 个,第三方 {len(third_party)} 个")
except Exception as e:
log_red(f"nodes.json 加载失败: {e}")
return _NODES return _NODES
_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条)
_SELECTED_RESTORED = False # 启动时是否已按设置恢复过上次选中的节点 def _third_party_summaries():
_NODES_SYNC_DONE = threading.Event() # 订阅同步线程完成信号(启动校验等它) """供 UI 展示,不向界面或日志暴露完整订阅 URL。"""
return [{
"id": str(record.get("id")),
"name": str(record.get("name") or "第三方订阅"),
"url": _mask_subscription_url(record.get("url") or ""),
"node_count": len(record.get("nodes") or []),
"updated_at": str(record.get("updated_at") or "未更新"),
"last_error": str(record.get("last_error") or ""),
} for record in _load_third_party_subscriptions()]
def _subscription_error_text(error):
"""保留格式错误提示,网络异常不回显可能含令牌的请求地址。"""
if isinstance(error, ValueError):
return str(error)
return "下载失败,请检查网络和订阅地址"
def _add_third_party_subscription(name, url):
name, url = str(name or "").strip(), str(url or "").strip()
if not name:
raise ValueError("请填写订阅名称")
if not url.lower().startswith(("https://", "http://")):
raise ValueError("订阅链接必须以 http:// 或 https:// 开头")
with _THIRD_PARTY_LOCK:
records = _load_third_party_subscriptions()
if any(str(record.get("url") or "") == url for record in records):
raise ValueError("该订阅链接已添加")
try:
nodes = _fetch_third_party_nodes(url)
except Exception as error:
raise ValueError(_subscription_error_text(error)) from None
records.append({
"id": uuid.uuid4().hex,
"name": name,
"url": url,
"nodes": nodes,
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"last_error": "",
})
_save_third_party_subscriptions(records)
_reload_nodes()
log(f"第三方订阅已添加: {name}({len(nodes)} 个节点)")
def _refresh_third_party_subscription(subscription_id):
with _THIRD_PARTY_LOCK:
records = _load_third_party_subscriptions()
record = next((item for item in records if str(item.get("id")) == str(subscription_id)), None)
if record is None:
raise ValueError("第三方订阅不存在")
try:
nodes = _fetch_third_party_nodes(str(record.get("url") or ""))
record["nodes"] = nodes
record["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
record["last_error"] = ""
_save_third_party_subscriptions(records)
_reload_nodes()
log(f"第三方订阅已更新: {record.get('name')}({len(nodes)} 个节点)")
except Exception as error:
record["last_error"] = _subscription_error_text(error)
_save_third_party_subscriptions(records)
raise ValueError(record["last_error"]) from None
def _remove_third_party_subscription(subscription_id):
with _THIRD_PARTY_LOCK:
records = _load_third_party_subscriptions()
selected = [item for item in records if str(item.get("id")) == str(subscription_id)]
if not selected:
return
_save_third_party_subscriptions([item for item in records if item not in selected])
_reload_nodes()
log(f"第三方订阅已删除: {selected[0].get('name')}")
def _sync_third_party_subscriptions():
"""启动后更新第三方订阅;失败时保留上次成功的节点缓存。"""
try:
for record in _load_third_party_subscriptions():
try:
_refresh_third_party_subscription(record.get("id"))
except Exception as error:
log(f"第三方订阅更新失败,继续使用本地缓存: {record.get('name')} ({_subscription_error_text(error)})")
finally:
_THIRD_PARTY_SYNC_DONE.set()
_SELECTED_NODE = 0
_SELECTED_RESTORED = False
_NODES_SYNC_DONE = threading.Event() # 官方订阅同步完成信号(启动校验等它)
_THIRD_PARTY_SYNC_DONE = threading.Event()
def _current_node(): def _current_node():
"""当前使用的节点:默认取列表第一个;无节点时返回 None。""" """当前使用的节点:默认取列表第一个;无节点时返回 None。"""
@@ -167,24 +335,30 @@ def _switch_node(index):
log("已在当前节点") log("已在当前节点")
return False return False
_SELECTED_NODE = index _SELECTED_NODE = index
_save_current_node(nodes[index].get("domain") or "") # 记录到设置,下次启动恢复 _save_current_node(nodes[index])
return True return True
def _restore_selected_node(nodes): def _restore_selected_node(nodes):
"""首次加载时按设置里记录的 current_node(域名)恢复上次选中的节点。""" """首次加载时按稳定节点标识恢复;兼容旧版仅按域名保存的设置。"""
global _SELECTED_NODE, _SELECTED_RESTORED global _SELECTED_NODE, _SELECTED_RESTORED
_SELECTED_RESTORED = True _SELECTED_RESTORED = True
saved_ref = str(_settings_load().get("current_node_ref") or "")
saved = str(_settings_load().get("current_node") or "") saved = str(_settings_load().get("current_node") or "")
for i, n in enumerate(nodes):
if saved_ref and _node_reference(n) == saved_ref:
_SELECTED_NODE = i
return
for i, n in enumerate(nodes): for i, n in enumerate(nodes):
if saved and str(n.get("domain") or "") == saved: if saved and str(n.get("domain") or "") == saved:
_SELECTED_NODE = i _SELECTED_NODE = i
return return
_SELECTED_NODE = 0 _SELECTED_NODE = 0
def _save_current_node(domain): def _save_current_node(node):
"""把当前选中节点域名写入设置文件(下次启动自动继续使用)。""" """保存来源与节点标识;保留旧域名字段以兼容已有 settings.json。"""
data = _settings_load() data = _settings_load()
data["current_node"] = str(domain or "") data["current_node"] = str((node or {}).get("domain") or "")
data["current_node_ref"] = _node_reference(node or {})
_settings_save(data) _settings_save(data)
# ── IPv6-only 节点可达性(客户端标注用)───────────────────── # ── IPv6-only 节点可达性(客户端标注用)─────────────────────
@@ -312,16 +486,21 @@ def _first_reachable_index(nodes):
return 0 return 0
def _rematch_saved_domain(): def _rematch_saved_domain():
"""按 settings 的 current_node(域名)在最新节点列表里重匹配下标。 """按保存的来源与节点标识重匹配,兼容旧版域名选择记录。"""
远程同步后服务端可能调整节点顺序:只按下标会静默连到别的节点。
返回 True 表示发生了切换。"""
global _SELECTED_NODE global _SELECTED_NODE
nodes = _load_nodes() nodes = _load_nodes()
if not nodes: if not nodes:
return False return False
saved_ref = str(_settings_load().get("current_node_ref") or "")
saved = str(_settings_load().get("current_node") or "") saved = str(_settings_load().get("current_node") or "")
cur = nodes[_SELECTED_NODE] if 0 <= _SELECTED_NODE < len(nodes) else None cur = nodes[_SELECTED_NODE] if 0 <= _SELECTED_NODE < len(nodes) else None
if saved_ref and (not cur or _node_reference(cur) != saved_ref):
match = next((i for i, n in enumerate(nodes)
if _node_reference(n) == saved_ref), None)
if match is not None:
_SELECTED_NODE = match
log(f"节点列表已更新,继续使用上次选择的节点 {nodes[match].get('domain')}")
return True
if saved and (not cur or str(cur.get("domain") or "") != saved): if saved and (not cur or str(cur.get("domain") or "") != saved):
match = next((i for i, n in enumerate(nodes) match = next((i for i, n in enumerate(nodes)
if str(n.get("domain") or "") == saved), None) if str(n.get("domain") or "") == saved), None)
@@ -349,7 +528,7 @@ def _verify_selected_node():
old = str(cur.get("domain") or "") old = str(cur.get("domain") or "")
new = str(nodes[idx].get("domain") or "") new = str(nodes[idx].get("domain") or "")
_SELECTED_NODE = idx _SELECTED_NODE = idx
_save_current_node(new) _save_current_node(nodes[idx])
if new and new != old: if new and new != old:
log_red(f"节点 {old} 在本机不可用,已自动切换到 {new}") log_red(f"节点 {old} 在本机不可用,已自动切换到 {new}")
return True return True
@@ -392,6 +571,14 @@ def _endpoint_port():
def _endpoint_country(): def _endpoint_country():
return _node_val("country", "") return _node_val("country", "")
def _endpoint_label():
"""主界面节点副标题;无节点时给出可操作的提示。"""
domain = _endpoint_domain()
if domain == "???":
return "无可用节点(请登录)"
return f"{domain} ({_endpoint_country()})"
def _proxy_auth_basic(): def _proxy_auth_basic():
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式) """正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。""" 或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
@@ -1091,16 +1278,8 @@ def _nodes_sync_remote():
_NODES_SYNC_DONE.set() # 无论成败都要放行启动校验 _NODES_SYNC_DONE.set() # 无论成败都要放行启动校验
def _user_init_local(): def _user_init_local():
"""初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。""" """初始化用户数据目录;无节点时允许用户跳过登录后再添加第三方订阅。"""
_USER_DIR.mkdir(parents=True, exist_ok=True) _USER_DIR.mkdir(parents=True, exist_ok=True)
if _user_path("nodes.json") is None:
import json
template = [{"domain": "", "port": 443, "country": "",
"username": "", "password": "", "remark": ""}]
(_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(
json.dumps(template, ensure_ascii=False, indent=2) + "\n"))
log_red("已创建节点配置文件(加密存储),请配置 config.json 的 "
"nodes_url 在线更新,或使用 others/nodes_tool 导入节点配置")
def _user_init_remote(base_url=None): def _user_init_remote(base_url=None):
"""检查并更新 PAC 数据(后台线程): """检查并更新 PAC 数据(后台线程):
@@ -1627,8 +1806,27 @@ def _whitelist_services():
) )
def _third_party_services():
return ThirdPartyServices(
load_icon=load_icon,
load_subscriptions=_third_party_summaries,
add_subscription=_add_third_party_subscription,
refresh_subscription=_refresh_third_party_subscription,
remove_subscription=_remove_third_party_subscription,
)
def _open_login_dialog(parent=None):
"""从启动流程或设置页登录官方订阅;成功后立即重载合并节点列表。"""
dlg = LoginDialog(_login_services(), parent)
if dlg.exec_() == QDialog.Accepted:
_reload_nodes()
return True
return False
def _logout_user_data(): def _logout_user_data():
"""清除本地登录凭据与节点文件,并让内存节点缓存失效。""" """清除官方登录与节点缓存;第三方订阅保持不变。"""
global _NODES global _NODES
for name in ("auth.json", "nodes.json"): for name in ("auth.json", "nodes.json"):
try: try:
@@ -1636,7 +1834,7 @@ def _logout_user_data():
except Exception: except Exception:
pass pass
_NODES = None _NODES = None
log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录") log("已退出登录:官方凭据与节点配置已清除,第三方订阅保持不变")
def _settings_services(): def _settings_services():
@@ -1651,6 +1849,9 @@ def _settings_services():
set_update_via_proxy=_set_update_via_proxy, set_update_via_proxy=_set_update_via_proxy,
set_skip_update_check=_set_skip_update_check, set_skip_update_check=_set_skip_update_check,
open_whitelist=lambda parent: WhitelistDialog(_whitelist_services(), parent).exec_(), open_whitelist=lambda parent: WhitelistDialog(_whitelist_services(), parent).exec_(),
open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(),
has_login=lambda: _auth_load() is not None,
open_login=_open_login_dialog,
logout=_logout_user_data, logout=_logout_user_data,
) )
@@ -1705,6 +1906,7 @@ class MainWindow(QWidget):
threading.Thread(target=_user_init_remote, daemon=True).start() threading.Thread(target=_user_init_remote, daemon=True).start()
# 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效) # 后台用已保存凭据同步最新节点配置(服务端改动下次启动生效)
threading.Thread(target=_nodes_sync_remote, daemon=True).start() threading.Thread(target=_nodes_sync_remote, daemon=True).start()
threading.Thread(target=_sync_third_party_subscriptions, daemon=True).start()
# 后台校验上次选中的节点在本机是否还可用(IPv6 专用节点本机无 IPv6 时回退) # 后台校验上次选中的节点在本机是否还可用(IPv6 专用节点本机无 IPv6 时回退)
threading.Thread(target=self._verify_node_thread, daemon=True).start() threading.Thread(target=self._verify_node_thread, daemon=True).start()
# 节点未配置时延迟弹出登录框(等窗口显示后) # 节点未配置时延迟弹出登录框(等窗口显示后)
@@ -1717,6 +1919,7 @@ class MainWindow(QWidget):
订阅同步可能较慢:等同步完成(最多 15 秒),同步后才校验, 订阅同步可能较慢:等同步完成(最多 15 秒),同步后才校验,
否则服务端换过节点顺序时校验的是旧列表。""" 否则服务端换过节点顺序时校验的是旧列表。"""
_NODES_SYNC_DONE.wait(15) _NODES_SYNC_DONE.wait(15)
_THIRD_PARTY_SYNC_DONE.wait(15)
try: try:
if _verify_selected_node(): if _verify_selected_node():
self._node_switched.emit() self._node_switched.emit()
@@ -1725,7 +1928,7 @@ class MainWindow(QWidget):
def _on_node_switched(self): def _on_node_switched(self):
"""节点被自动切换后刷新标题栏(在 UI 线程执行)。""" """节点被自动切换后刷新标题栏(在 UI 线程执行)。"""
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})") self.subtitle.setText(_endpoint_label())
def init_ui(self): def init_ui(self):
"""初始化主界面布局与样式。""" """初始化主界面布局与样式。"""
@@ -1759,7 +1962,7 @@ class MainWindow(QWidget):
prefix = QLabel("节点: ") prefix = QLabel("节点: ")
prefix.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;") prefix.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;")
node_row.addWidget(prefix) node_row.addWidget(prefix)
self.subtitle = NodeLabel(f"{_endpoint_domain()} ({_endpoint_country()})") self.subtitle = NodeLabel(_endpoint_label())
self.subtitle.clicked.connect(self._show_node_menu) self.subtitle.clicked.connect(self._show_node_menu)
node_row.addWidget(self.subtitle) node_row.addWidget(self.subtitle)
node_row.addStretch() node_row.addStretch()
@@ -1954,11 +2157,13 @@ class MainWindow(QWidget):
def on_start(self): def on_start(self):
if self._start_in_progress: if self._start_in_progress:
return return
# 节点未配置:先弹出登录框,成功后继续;取消/失败则中止开启 # 未配置节点时允许先登录;跳过后仍留在主界面,可到设置页添加第三方订阅。
if _endpoint_domain() == "???": if _endpoint_domain() == "???":
self._show_login_dialog() self._show_login_dialog()
if _endpoint_domain() == "???": if _endpoint_domain() == "???":
log_red("未登录,无法开启代理") message = "未配置可用节点。请在设置中登录,或在「第三方」中添加订阅。"
log_red(message)
QMessageBox.information(self, "无法开启代理", message)
return return
self.cb_pac.setEnabled(False) self.cb_pac.setEnabled(False)
self.cb_bypass_lan.setEnabled(False) self.cb_bypass_lan.setEnabled(False)
@@ -2093,28 +2298,19 @@ class MainWindow(QWidget):
# ── 设置 ── # ── 设置 ──
def _show_settings_dialog(self): def _show_settings_dialog(self):
"""打开设置弹窗(代理运行中锁定端口修改);退出登录后立即触发登录流程。""" """打开设置弹窗;第三方订阅与登录状态可独立管理。"""
SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_() SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_()
self._refresh_subtitle() self._refresh_subtitle()
if _endpoint_domain() == "???":
self._maybe_show_login() # 退出登录后重新走登录(隐藏主窗口/取消退出)
# ── 节点登录 ── # ── 节点登录 ──
def _maybe_show_login(self): def _maybe_show_login(self):
"""启动时节点未配置:隐藏主窗口只显示登录框;登录成功恢复,取消则退出。""" """首次无节点时提示登录;用户可跳过并在主界面添加第三方订阅。"""
if _endpoint_domain() == "???": if _endpoint_domain() == "???":
self.hide()
self._show_login_dialog() self._show_login_dialog()
if _endpoint_domain() == "???":
self.quit_app() # 未登录且无可用节点,直接退出
return
self.show()
def _show_login_dialog(self): def _show_login_dialog(self):
"""弹出登录框;成功后重载节点并刷新标题。""" """弹出登录框;成功后重载节点并刷新标题。"""
dlg = LoginDialog(_login_services(), self) if _open_login_dialog(self):
if dlg.exec_() == QDialog.Accepted:
_reload_nodes()
self._refresh_subtitle() self._refresh_subtitle()
# ── 节点切换 ── # ── 节点切换 ──
@@ -2126,8 +2322,8 @@ class MainWindow(QWidget):
nodes = _reload_nodes() nodes = _reload_nodes()
if not nodes: if not nodes:
QMessageBox.information(self, "提示", QMessageBox.information(self, "提示",
"nodes.json 中没有可用节点。\n" "当前没有可用节点。\n"
"可在右下角「设置」中退出登录后重新登录获取节点配置。") "请在右下角「设置」中登录,或在「第三方」中添加订阅。")
return return
menu = self._build_node_menu(nodes) menu = self._build_node_menu(nodes)
menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft())) menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft()))
@@ -2160,6 +2356,8 @@ class MainWindow(QWidget):
text = f"{domain} ({country})" text = f"{domain} ({country})"
if remark and remark != domain: if remark and remark != domain:
text += f" {remark}" text += f" {remark}"
source_name = n.get("_rrpp_source_name") or "官方订阅"
text += f" [{source_name}]"
ipv6_only = _node_ipv6_only(n) ipv6_only = _node_ipv6_only(n)
reachable = _node_reachable(n) reachable = _node_reachable(n)
if ipv6_only: if ipv6_only:
@@ -2189,7 +2387,7 @@ class MainWindow(QWidget):
def _refresh_subtitle(self): def _refresh_subtitle(self):
"""刷新节点副标题文字。""" """刷新节点副标题文字。"""
self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})") self.subtitle.setText(_endpoint_label())
def _update_node_label(self): def _update_node_label(self):
"""代理运行中时提示禁止切换节点。""" """代理运行中时提示禁止切换节点。"""
@@ -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 "已隐藏订阅地址"
+200 -6
View File
@@ -103,8 +103,9 @@ class LoginDialog(QDialog):
hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;") hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
bottom.addWidget(hint) bottom.addWidget(hint)
bottom.addStretch() bottom.addStretch()
self.btn_cancel = QPushButton("取消") self.btn_cancel = QPushButton("跳过登录")
self.btn_cancel.setObjectName("btnCancel") self.btn_cancel.setObjectName("btnCancel")
self.btn_cancel.setToolTip("可稍后在设置中登录,或添加第三方订阅")
self.btn_cancel.clicked.connect(self.reject) self.btn_cancel.clicked.connect(self.reject)
bottom.addWidget(self.btn_cancel) bottom.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("登录") self.btn_ok = QPushButton("登录")
@@ -291,6 +292,175 @@ class WhitelistDialog(QDialog):
self.accept() self.accept()
@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) @dataclass(frozen=True)
class SettingsServices: class SettingsServices:
"""设置框所需的配置与业务回调。""" """设置框所需的配置与业务回调。"""
@@ -305,6 +475,9 @@ class SettingsServices:
set_update_via_proxy: object set_update_via_proxy: object
set_skip_update_check: object set_skip_update_check: object
open_whitelist: object open_whitelist: object
open_third_party: object
has_login: object
open_login: object
logout: object logout: object
@@ -316,7 +489,7 @@ class SettingsDialog(QDialog):
self._services = services self._services = services
self.setWindowTitle("设置") self.setWindowTitle("设置")
self.setWindowIcon(services.load_icon()) self.setWindowIcon(services.load_icon())
self.setFixedSize(300, 354) self.setFixedSize(300, 396)
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; }}
@@ -371,6 +544,19 @@ class SettingsDialog(QDialog):
whitelist_row.addWidget(self.btn_whitelist) whitelist_row.addWidget(self.btn_whitelist)
layout.addLayout(whitelist_row) layout.addLayout(whitelist_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)
port_row = QHBoxLayout() port_row = QHBoxLayout()
port_label = QLabel("代理转发端口") port_label = QLabel("代理转发端口")
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;") port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
@@ -415,13 +601,13 @@ class SettingsDialog(QDialog):
self.btn_save.setEnabled(not port_locked) self.btn_save.setEnabled(not port_locked)
self.btn_save.clicked.connect(self._save_settings) self.btn_save.clicked.connect(self._save_settings)
buttons.addWidget(self.btn_save) buttons.addWidget(self.btn_save)
self.btn_logout = QPushButton("退出登录") self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
self.btn_logout.setStyleSheet(f""" self.btn_logout.setStyleSheet(f"""
QPushButton {{ background-color: {C_RED}; color: #fff; border: none; border-radius: 6px; QPushButton {{ background-color: {C_RED}; color: #fff; border: none; border-radius: 6px;
padding: 5px 14px; font-size: 12px; font-weight: bold; }} padding: 5px 14px; font-size: 12px; font-weight: bold; }}
QPushButton:hover {{ background-color: #ff8787; }} QPushButton:hover {{ background-color: #ff8787; }}
""") """)
self.btn_logout.setToolTip("清除登录凭据与本地节点配置,下次启动需重新登录") self.btn_logout.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
self.btn_logout.clicked.connect(self._logout) self.btn_logout.clicked.connect(self._logout)
buttons.addWidget(self.btn_logout) buttons.addWidget(self.btn_logout)
buttons.addStretch() buttons.addStretch()
@@ -450,6 +636,9 @@ class SettingsDialog(QDialog):
def _open_whitelist(self): def _open_whitelist(self):
self._services.open_whitelist(self) self._services.open_whitelist(self)
def _open_third_party(self):
self._services.open_third_party(self)
def _save_settings(self): def _save_settings(self):
port = self._services.parse_port(self.ed_port.text()) port = self._services.parse_port(self.ed_port.text())
if port is None: if port is None:
@@ -464,5 +653,10 @@ class SettingsDialog(QDialog):
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):
self._services.logout() if self._services.has_login():
self.accept() self._services.logout()
self.btn_logout.setText("登录")
else:
self._services.open_login(self)
if self._services.has_login():
self.btn_logout.setText("退出登录")