新增功能:

- 节点配置与 PAC 数据改为 ~/RRPP 存储,首次启动自动初始化(nodes.json 缺失时生成模板)
- PAC 数据在线更新:按 pac_manifest.json 的资源号/日期比对,从 CDN 直连拉取(无需认证),失败自动使用本地副本
- nodes.json 支持在线更新(独立地址,按内容比对,原子覆盖)
- 打包仅内置 config.json,nodes/PAC 不再进 EXE
This commit is contained in:
RoyirinRosmontis
2026-08-23 12:11:59 +08:00
parent f518819d8a
commit 254f78cc04
+142 -7
View File
@@ -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):
"""初始化主界面布局与样式。"""