diff --git a/lite/config.json b/lite/config.json index 6d5d72b..6e23e0e 100644 --- a/lite/config.json +++ b/lite/config.json @@ -1,6 +1,6 @@ { - "version": "", - "update_url": "", - "pac_base_url": "", - "nodes_url": "" + "version": "999909162305", + "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" } diff --git a/lite/main.py b/lite/main.py index c96163f..e260cf7 100644 --- a/lite/main.py +++ b/lite/main.py @@ -1,6 +1,6 @@ # -*- 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 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.widgets import NodeLabel, StatusDot, ToggleSwitch from rrpp_ui.dialogs import ( - LoginDialog, LoginServices, SettingsDialog, SettingsServices, + LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices, WhitelistDialog, WhitelistServices, ) from rrpp_core.routing_primitives import ( @@ -45,6 +45,10 @@ from rrpp_core.node_api import ( b64url as _b64url, 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 ( is_private_host as _is_private_host, is_private_ipv4_int as _is_private_int, @@ -110,39 +114,203 @@ def _load_config(): def _config_val(key, default=""): return _load_config().get(key, default) -# ── 节点配置(nodes.json)──────────────────────────── -# 节点列表,当前固定取第一个(后续可扩展选择) +# ── 节点配置(官方 nodes.json + 第三方订阅)──────────────── +# 官方节点和第三方节点分别保存;加载时才合并,避免登录更新覆盖第三方缓存。 _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(): - """加载 nodes.json 节点列表(用户数据目录优先);失败或缺失时返回空列表。""" + """加载并合并官方节点与每个第三方订阅的上次成功缓存。""" global _NODES if _NODES is not None: return _NODES - _NODES = [] + official = [] try: - import json path = _user_path("nodes.json") - if path is None: - log_red("nodes.json 缺失,无法加载节点配置") - return _NODES - data = json.loads(_nodes_decrypt(Path(path).read_bytes())) - if not isinstance(data, list): - raise ValueError("nodes.json 顶层应为节点数组") - _NODES = data - if not _SELECTED_RESTORED: - _restore_selected_node(_NODES) # 恢复上次使用的节点 - log(f"已加载节点配置: {len(_NODES)} 个") - if data and not str(data[0].get("domain") or "").strip(): - log_red("节点未填写域名:请配置 config.json 的 nodes_url 在线更新," - "或使用 others/nodes_tool 导入节点配置") - except Exception as e: - log_red(f"nodes.json 加载失败: {e}") + if path is not None: + data = json.loads(_nodes_decrypt(Path(path).read_bytes())) + if not isinstance(data, list): + raise ValueError("nodes.json 顶层应为节点数组") + official = _tag_official_nodes(data) + except Exception as error: + log_red(f"官方节点配置读取失败: {error}") + third_party = _subscription_nodes(_load_third_party_subscriptions()) + _NODES = official + third_party + if not _SELECTED_RESTORED: + _restore_selected_node(_NODES) + if _NODES: + log(f"已加载节点配置: 官方 {len(official)} 个,第三方 {len(third_party)} 个") return _NODES -_SELECTED_NODE = 0 # 当前选中节点下标(默认第一条) -_SELECTED_RESTORED = False # 启动时是否已按设置恢复过上次选中的节点 -_NODES_SYNC_DONE = threading.Event() # 订阅同步线程完成信号(启动校验等它) + +def _third_party_summaries(): + """供 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(): """当前使用的节点:默认取列表第一个;无节点时返回 None。""" @@ -167,24 +335,30 @@ def _switch_node(index): log("已在当前节点") return False _SELECTED_NODE = index - _save_current_node(nodes[index].get("domain") or "") # 记录到设置,下次启动恢复 + _save_current_node(nodes[index]) return True def _restore_selected_node(nodes): - """首次加载时按设置里记录的 current_node(域名)恢复上次选中的节点。""" + """首次加载时按稳定节点标识恢复;兼容旧版仅按域名保存的设置。""" global _SELECTED_NODE, _SELECTED_RESTORED _SELECTED_RESTORED = True + saved_ref = str(_settings_load().get("current_node_ref") 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): if saved and str(n.get("domain") or "") == saved: _SELECTED_NODE = i return _SELECTED_NODE = 0 -def _save_current_node(domain): - """把当前选中节点域名写入设置文件(下次启动自动继续使用)。""" +def _save_current_node(node): + """保存来源与节点标识;保留旧域名字段以兼容已有 settings.json。""" 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) # ── IPv6-only 节点可达性(客户端标注用)───────────────────── @@ -312,16 +486,21 @@ def _first_reachable_index(nodes): return 0 def _rematch_saved_domain(): - """按 settings 的 current_node(域名)在最新节点列表里重匹配下标。 - - 远程同步后服务端可能调整节点顺序:只按下标会静默连到别的节点。 - 返回 True 表示发生了切换。""" + """按保存的来源与节点标识重匹配,兼容旧版域名选择记录。""" global _SELECTED_NODE nodes = _load_nodes() if not nodes: return False + saved_ref = str(_settings_load().get("current_node_ref") or "") saved = str(_settings_load().get("current_node") or "") 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): match = next((i for i, n in enumerate(nodes) if str(n.get("domain") or "") == saved), None) @@ -349,7 +528,7 @@ def _verify_selected_node(): old = str(cur.get("domain") or "") new = str(nodes[idx].get("domain") or "") _SELECTED_NODE = idx - _save_current_node(new) + _save_current_node(nodes[idx]) if new and new != old: log_red(f"节点 {old} 在本机不可用,已自动切换到 {new}") return True @@ -392,6 +571,14 @@ def _endpoint_port(): def _endpoint_country(): return _node_val("country", "") + +def _endpoint_label(): + """主界面节点副标题;无节点时给出可操作的提示。""" + domain = _endpoint_domain() + if domain == "???": + return "无可用节点(请登录)" + return f"{domain} ({_endpoint_country()})" + def _proxy_auth_basic(): """正向代理 Basic 认证凭证:methods.http.{username,password}(新格式) 或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。""" @@ -1091,16 +1278,8 @@ def _nodes_sync_remote(): _NODES_SYNC_DONE.set() # 无论成败都要放行启动校验 def _user_init_local(): - """初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。""" + """初始化用户数据目录;无节点时允许用户跳过登录后再添加第三方订阅。""" _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): """检查并更新 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(): - """清除本地登录凭据与节点文件,并让内存节点缓存失效。""" + """清除官方登录与节点缓存;第三方订阅保持不变。""" global _NODES for name in ("auth.json", "nodes.json"): try: @@ -1636,7 +1834,7 @@ def _logout_user_data(): except Exception: pass _NODES = None - log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录") + log("已退出登录:官方凭据与节点配置已清除,第三方订阅保持不变") def _settings_services(): @@ -1651,6 +1849,9 @@ def _settings_services(): set_update_via_proxy=_set_update_via_proxy, set_skip_update_check=_set_skip_update_check, 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, ) @@ -1705,6 +1906,7 @@ class MainWindow(QWidget): threading.Thread(target=_user_init_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 时回退) threading.Thread(target=self._verify_node_thread, daemon=True).start() # 节点未配置时延迟弹出登录框(等窗口显示后) @@ -1717,6 +1919,7 @@ class MainWindow(QWidget): 订阅同步可能较慢:等同步完成(最多 15 秒),同步后才校验, 否则服务端换过节点顺序时校验的是旧列表。""" _NODES_SYNC_DONE.wait(15) + _THIRD_PARTY_SYNC_DONE.wait(15) try: if _verify_selected_node(): self._node_switched.emit() @@ -1725,7 +1928,7 @@ class MainWindow(QWidget): def _on_node_switched(self): """节点被自动切换后刷新标题栏(在 UI 线程执行)。""" - self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})") + self.subtitle.setText(_endpoint_label()) def init_ui(self): """初始化主界面布局与样式。""" @@ -1759,7 +1962,7 @@ class MainWindow(QWidget): prefix = QLabel("节点: ") prefix.setStyleSheet(f"color: {C_TITLE}; font-size: 12px;") 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) node_row.addWidget(self.subtitle) node_row.addStretch() @@ -1954,11 +2157,13 @@ class MainWindow(QWidget): def on_start(self): if self._start_in_progress: return - # 节点未配置:先弹出登录框,成功后继续;取消/失败则中止开启 + # 未配置节点时允许先登录;跳过后仍留在主界面,可到设置页添加第三方订阅。 if _endpoint_domain() == "???": self._show_login_dialog() if _endpoint_domain() == "???": - log_red("未登录,无法开启代理") + message = "未配置可用节点。请在设置中登录,或在「第三方」中添加订阅。" + log_red(message) + QMessageBox.information(self, "无法开启代理", message) return self.cb_pac.setEnabled(False) self.cb_bypass_lan.setEnabled(False) @@ -2093,28 +2298,19 @@ class MainWindow(QWidget): # ── 设置 ── def _show_settings_dialog(self): - """打开设置弹窗(代理运行中锁定端口修改);退出登录后立即触发登录流程。""" + """打开设置弹窗;第三方订阅与登录状态可独立管理。""" SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_() self._refresh_subtitle() - if _endpoint_domain() == "???": - self._maybe_show_login() # 退出登录后重新走登录(隐藏主窗口/取消退出) # ── 节点登录 ── def _maybe_show_login(self): - """启动时节点未配置:隐藏主窗口只显示登录框;登录成功恢复,取消则退出。""" + """首次无节点时提示登录;用户可跳过并在主界面添加第三方订阅。""" if _endpoint_domain() == "???": - self.hide() self._show_login_dialog() - if _endpoint_domain() == "???": - self.quit_app() # 未登录且无可用节点,直接退出 - return - self.show() def _show_login_dialog(self): """弹出登录框;成功后重载节点并刷新标题。""" - dlg = LoginDialog(_login_services(), self) - if dlg.exec_() == QDialog.Accepted: - _reload_nodes() + if _open_login_dialog(self): self._refresh_subtitle() # ── 节点切换 ── @@ -2126,8 +2322,8 @@ class MainWindow(QWidget): nodes = _reload_nodes() if not nodes: QMessageBox.information(self, "提示", - "nodes.json 中没有可用节点。\n" - "可在右下角「设置」中退出登录后重新登录获取节点配置。") + "当前没有可用节点。\n" + "请在右下角「设置」中登录,或在「第三方」中添加订阅。") return menu = self._build_node_menu(nodes) menu.exec_(self.subtitle.mapToGlobal(self.subtitle.rect().bottomLeft())) @@ -2160,6 +2356,8 @@ class MainWindow(QWidget): text = f"{domain} ({country})" if remark and remark != domain: text += f" {remark}" + source_name = n.get("_rrpp_source_name") or "官方订阅" + text += f" [{source_name}]" ipv6_only = _node_ipv6_only(n) reachable = _node_reachable(n) if ipv6_only: @@ -2189,7 +2387,7 @@ class MainWindow(QWidget): def _refresh_subtitle(self): """刷新节点副标题文字。""" - self.subtitle.setText(f"{_endpoint_domain()} ({_endpoint_country()})") + self.subtitle.setText(_endpoint_label()) def _update_node_label(self): """代理运行中时提示禁止切换节点。""" diff --git a/lite/rrpp_client_nodes.template.json b/lite/rrpp_client_nodes.template.json new file mode 100644 index 0000000..62a6132 --- /dev/null +++ b/lite/rrpp_client_nodes.template.json @@ -0,0 +1,10 @@ +[ + { + "domain": "proxy.example.invalid", + "port": 443, + "country": "US", + "username": "replace-with-proxy-username", + "password": "replace-with-proxy-password", + "remark": "示例节点(替换全部连接信息后导入)" + } +] diff --git a/lite/rrpp_core/third_party_subscriptions.py b/lite/rrpp_core/third_party_subscriptions.py new file mode 100644 index 0000000..7fbbec7 --- /dev/null +++ b/lite/rrpp_core/third_party_subscriptions.py @@ -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 "已隐藏订阅地址" diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py index cc81155..9ad01ea 100644 --- a/lite/rrpp_ui/dialogs.py +++ b/lite/rrpp_ui/dialogs.py @@ -103,8 +103,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 +292,175 @@ class WhitelistDialog(QDialog): 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) class SettingsServices: """设置框所需的配置与业务回调。""" @@ -305,6 +475,9 @@ class SettingsServices: set_update_via_proxy: object set_skip_update_check: object open_whitelist: object + open_third_party: object + has_login: object + open_login: object logout: object @@ -316,7 +489,7 @@ class SettingsDialog(QDialog): self._services = services self.setWindowTitle("设置") self.setWindowIcon(services.load_icon()) - self.setFixedSize(300, 354) + self.setFixedSize(300, 396) self.setStyleSheet(f""" QDialog {{ background-color: {C_BG}; }} 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) 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_label = QLabel("代理转发端口") 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.clicked.connect(self._save_settings) buttons.addWidget(self.btn_save) - self.btn_logout = QPushButton("退出登录") + self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录") 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.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅") self.btn_logout.clicked.connect(self._logout) buttons.addWidget(self.btn_logout) buttons.addStretch() @@ -450,6 +636,9 @@ class SettingsDialog(QDialog): def _open_whitelist(self): self._services.open_whitelist(self) + def _open_third_party(self): + self._services.open_third_party(self) + def _save_settings(self): port = self._services.parse_port(self.ed_port.text()) if port is None: @@ -464,5 +653,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("退出登录")