From 254f78cc04e56b4ecc99fc729304b35164b3bda0 Mon Sep 17 00:00:00 2001 From: RoyirinRosmontis Date: Sun, 23 Aug 2026 12:11:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=20-=20=E8=8A=82=E7=82=B9=E9=85=8D=E7=BD=AE=E4=B8=8E=20PAC=20?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=94=B9=E4=B8=BA=20~/RRPP=20=E5=AD=98?= =?UTF-8?q?=E5=82=A8=EF=BC=8C=E9=A6=96=E6=AC=A1=E5=90=AF=E5=8A=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=9D=E5=A7=8B=E5=8C=96=EF=BC=88nodes.json=20?= =?UTF-8?q?=E7=BC=BA=E5=A4=B1=E6=97=B6=E7=94=9F=E6=88=90=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=EF=BC=89=20-=20PAC=20=E6=95=B0=E6=8D=AE=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=9A=E6=8C=89=20pac=5Fmanifest.json=20?= =?UTF-8?q?=E7=9A=84=E8=B5=84=E6=BA=90=E5=8F=B7/=E6=97=A5=E6=9C=9F?= =?UTF-8?q?=E6=AF=94=E5=AF=B9=EF=BC=8C=E4=BB=8E=20CDN=20=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E6=8B=89=E5=8F=96=EF=BC=88=E6=97=A0=E9=9C=80=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E5=A4=B1=E8=B4=A5=E8=87=AA=E5=8A=A8=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=9C=AC=E5=9C=B0=E5=89=AF=E6=9C=AC=20-=20nodes.json?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E5=9C=A8=E7=BA=BF=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=88=E7=8B=AC=E7=AB=8B=E5=9C=B0=E5=9D=80=EF=BC=8C=E6=8C=89?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=AF=94=E5=AF=B9=EF=BC=8C=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E8=A6=86=E7=9B=96=EF=BC=89=20-=20=E6=89=93=E5=8C=85=E4=BB=85?= =?UTF-8?q?=E5=86=85=E7=BD=AE=20config.json=EF=BC=8Cnodes/PAC=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=BF=9B=20EXE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lite/proxy_lite.py | 149 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 142 insertions(+), 7 deletions(-) diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index 96b6528..2028709 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -73,14 +73,14 @@ def _config_val(key, default=""): _NODES = None def _load_nodes(): - """加载 nodes.json 节点列表;失败或缺失时返回空列表。""" + """加载 nodes.json 节点列表(~/RRPP 优先);失败或缺失时返回空列表。""" global _NODES if _NODES is not None: return _NODES _NODES = [] try: import json - path = _data_path("nodes.json") + path = _user_path("nodes.json") if path is None: log_red("nodes.json 缺失,无法加载节点配置") return _NODES @@ -88,7 +88,9 @@ def _load_nodes(): if not isinstance(data, list): raise ValueError("nodes.json 顶层应为节点数组") _NODES = data - log(f"已加载节点配置: {len(_NODES)} 个 (nodes.json)") + log(f"已加载节点配置: {len(_NODES)} 个 ({path})") + if data and not str(data[0].get("domain") or "").strip(): + log_red(f"节点未填写域名,请编辑 {path} 后重启程序") except Exception as e: log_red(f"nodes.json 加载失败: {e}") return _NODES @@ -128,7 +130,10 @@ def _reload_nodes(): def _node_val(key, default=""): node = _current_node() - return node.get(key, default) if node else default + if not node: + return default + v = node.get(key, default) + return v if v not in (None, "") else default # 空字符串视为未填写 def _endpoint_domain(): return _node_val("domain", "???") @@ -512,15 +517,141 @@ _FORCE_PROXY_SUFFIXES = None _FORCE_PROXY_LOADED = False def _data_path(name: str): - """数据文件路径:只取打包内置目录(单文件 EXE,不依赖外部文件)。""" + """数据文件路径:只取打包内置目录(config.json 随 exe 内置)。""" p = Path(BASE_DIR) / name return p if p.is_file() else None +_USER_DIR = Path.home() / "RRPP" # 用户数据目录(nodes.json / PAC 数据),测试可覆盖 + +def _user_path(name: str): + """用户数据文件路径:~/RRPP 优先,回退内置/源码目录(dev 模式与测试)。""" + p = _USER_DIR / name + if p.is_file(): + return p + return _data_path(name) + # 以下模块级取值需在 _data_path 定义之后执行 VERSION = _config_val("version", "999908162319") _TLS_LOCAL_PORT = int(_config_val("local_port", 1080)) # 本地转发器监听端口 PROXY_PORT = _TLS_LOCAL_PORT +# ── PAC 数据在线更新(CDN 直连,无需认证) ───────────── +# 更新源地址取 config.json 的 pac_base_url;目录结构: +# {pac_base_url}/pac_manifest.json 资源号清单 +# {pac_base_url}/pac_domains.json 强制隧道域名表(version/date 字段随文件下发) +# {pac_base_url}/pac_ip_cn.json 中国 IP 段(同上) +_PAC_FILES = ("pac_domains", "pac_ip_cn") + +def _pac_read_version(path): + """读取 PAC 数据文件内的 version/date 字段。""" + try: + import json + data = json.loads(Path(path).read_text("utf-8")) + return str(data.get("version") or ""), str(data.get("date") or "") + except Exception: + return "", "" + +def _pac_no_proxy_opener(): + """直连 opener:显式空代理,忽略系统/环境代理设置(CDN 无需认证)。""" + import urllib.request + return urllib.request.build_opener(urllib.request.ProxyHandler({})) + +def _pac_fetch_json(url, timeout=15, tries=2): + opener = _pac_no_proxy_opener() + import json + for i in range(tries): + try: + with opener.open(url, timeout=timeout) as r: + return json.loads(r.read().decode("utf-8")) + except Exception: + if i == tries - 1: + raise + +def _pac_validate(name, data): + if name == "pac_domains": + return isinstance(data.get("domains"), list) + if name == "pac_ip_cn": + return isinstance(data.get("cidrs"), list) + return False + +def _pac_download(base, name): + """下载 PAC 数据并原子写入 ~/RRPP;返回 (version, date)。""" + import json + data = _pac_fetch_json(f"{base}/{name}.json") + if not _pac_validate(name, data): + raise ValueError(f"{name}.json 数据格式不正确") + _USER_DIR.mkdir(parents=True, exist_ok=True) + tmp = _USER_DIR / f"{name}.json.tmp" + tmp.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + os.replace(tmp, _USER_DIR / f"{name}.json") # 原子替换,避免写一半 + return str(data.get("version") or ""), str(data.get("date") or "") + +def _user_init_local(): + """初始化 ~/RRPP(同步):建目录,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_text( + json.dumps(template, ensure_ascii=False, indent=2) + "\n", "utf-8") + log_red(f"已创建 {_USER_DIR / 'nodes.json'} 模板,请填写节点配置后重启程序") + +def _user_init_remote(base_url=None, nodes_url=None): + """检查并更新 PAC 数据与节点配置(后台线程): + PAC 按 manifest 版本比对(失败用本地副本);nodes 按内容比对(独立地址)。 + """ + base = (base_url or str(_config_val("pac_base_url", "") or "")).strip().rstrip("/") + if not base: + log("未配置 PAC 更新源(config.json 的 pac_base_url),使用本地数据") + else: + try: + manifest = _pac_fetch_json(f"{base}/pac_manifest.json") + except Exception as e: + log(f"PAC 更新源不可达,使用本地数据 ({e})") + manifest = None + if manifest is not None: + updated = 0 + for name in _PAC_FILES: + remote = manifest.get(name) or {} + rver = str(remote.get("version") or "") + rdate = str(remote.get("date") or "") + # 版本只与 ~/RRPP 本地文件比对(回退文件仅用于读取,不参与更新判定) + local = _USER_DIR / f"{name}.json" + lver = _pac_read_version(local)[0] if local.is_file() else "" + if rver and rver > lver: + try: + ver, date = _pac_download(base, name) + log(f"PAC 数据已更新: {name}.json {ver or rver} ({date or rdate})") + updated += 1 + except Exception as e: + log(f"PAC 数据 {name}.json 更新失败,使用本地副本: {e}") + if updated == 0 and (_USER_DIR / "pac_domains.json").is_file(): + log("PAC 数据已是最新") + # 节点配置在线更新:地址独立于 PAC,按内容比对,仅变化时原子覆盖 + nurl = (nodes_url or str(_config_val("nodes_url", "") or "")).strip() + if not nurl: + return + try: + import json + data = _pac_fetch_json(nurl) + if not (isinstance(data, list) and data + and all(isinstance(n, dict) for n in data)): + raise ValueError("nodes.json 数据格式不正确") + new_bytes = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + b"\n" + local = _USER_DIR / "nodes.json" + if local.is_file() and local.read_bytes() == new_bytes: + return # 内容无变化 + _USER_DIR.mkdir(parents=True, exist_ok=True) + tmp = _USER_DIR / "nodes.json.tmp" + tmp.write_bytes(new_bytes) + os.replace(tmp, local) + global _NODES + _NODES = None # 使后续读取(节点菜单等)拿到新配置 + log(f"节点配置已更新: {len(data)} 个节点 (nodes.json)") + except Exception as e: + log(f"节点配置在线更新失败,使用本地配置: {e}") + def _load_cn_ranges(): """加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。""" global _CN_RANGES, _CN_RANGES_LOADED @@ -529,7 +660,7 @@ def _load_cn_ranges(): _CN_RANGES_LOADED = True try: import json, ipaddress - path = _data_path("pac_ip_cn.json") + path = _user_path("pac_ip_cn.json") if path is None: log_red("未找到 pac_ip_cn.json,智能分流不可用(全部走隧道)") return _CN_RANGES @@ -563,7 +694,7 @@ def _load_force_proxy_suffixes(): _FORCE_PROXY_LOADED = True try: import json - path = _data_path("pac_domains.json") + path = _user_path("pac_domains.json") if path is None: return _FORCE_PROXY_SUFFIXES # 可选文件,缺失不影响 IP 分流 data = json.loads(Path(path).read_text("utf-8")) @@ -964,6 +1095,8 @@ class MainWindow(QWidget): self._update_download_done.connect(self._on_update_download_done) self._status_ready.connect(self._apply_status) + _user_init_local() # 首启初始化 ~/RRPP(节点模板),须在 init_ui 读节点之前 + self.init_ui() # 每 2 秒刷新一次状态指示灯 @@ -974,6 +1107,8 @@ class MainWindow(QWidget): # 启动时后台检查更新,完成前禁用开启按钮 threading.Thread(target=self._check_update, daemon=True).start() + # 后台检查 PAC 数据更新(首次启动自动拉取到 ~/RRPP) + threading.Thread(target=_user_init_remote, daemon=True).start() def init_ui(self): """初始化主界面布局与样式。"""