新增功能:
- 节点配置与 PAC 数据改为 ~/RRPP 存储,首次启动自动初始化(nodes.json 缺失时生成模板) - PAC 数据在线更新:按 pac_manifest.json 的资源号/日期比对,从 CDN 直连拉取(无需认证),失败自动使用本地副本 - nodes.json 支持在线更新(独立地址,按内容比对,原子覆盖) - 打包仅内置 config.json,nodes/PAC 不再进 EXE
This commit is contained in:
+142
-7
@@ -73,14 +73,14 @@ def _config_val(key, default=""):
|
|||||||
_NODES = None
|
_NODES = None
|
||||||
|
|
||||||
def _load_nodes():
|
def _load_nodes():
|
||||||
"""加载 nodes.json 节点列表;失败或缺失时返回空列表。"""
|
"""加载 nodes.json 节点列表(~/RRPP 优先);失败或缺失时返回空列表。"""
|
||||||
global _NODES
|
global _NODES
|
||||||
if _NODES is not None:
|
if _NODES is not None:
|
||||||
return _NODES
|
return _NODES
|
||||||
_NODES = []
|
_NODES = []
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
path = _data_path("nodes.json")
|
path = _user_path("nodes.json")
|
||||||
if path is None:
|
if path is None:
|
||||||
log_red("nodes.json 缺失,无法加载节点配置")
|
log_red("nodes.json 缺失,无法加载节点配置")
|
||||||
return _NODES
|
return _NODES
|
||||||
@@ -88,7 +88,9 @@ def _load_nodes():
|
|||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
raise ValueError("nodes.json 顶层应为节点数组")
|
raise ValueError("nodes.json 顶层应为节点数组")
|
||||||
_NODES = data
|
_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:
|
except Exception as e:
|
||||||
log_red(f"nodes.json 加载失败: {e}")
|
log_red(f"nodes.json 加载失败: {e}")
|
||||||
return _NODES
|
return _NODES
|
||||||
@@ -128,7 +130,10 @@ def _reload_nodes():
|
|||||||
|
|
||||||
def _node_val(key, default=""):
|
def _node_val(key, default=""):
|
||||||
node = _current_node()
|
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():
|
def _endpoint_domain():
|
||||||
return _node_val("domain", "???")
|
return _node_val("domain", "???")
|
||||||
@@ -512,15 +517,141 @@ _FORCE_PROXY_SUFFIXES = None
|
|||||||
_FORCE_PROXY_LOADED = False
|
_FORCE_PROXY_LOADED = False
|
||||||
|
|
||||||
def _data_path(name: str):
|
def _data_path(name: str):
|
||||||
"""数据文件路径:只取打包内置目录(单文件 EXE,不依赖外部文件)。"""
|
"""数据文件路径:只取打包内置目录(config.json 随 exe 内置)。"""
|
||||||
p = Path(BASE_DIR) / name
|
p = Path(BASE_DIR) / name
|
||||||
return p if p.is_file() else None
|
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 定义之后执行
|
# 以下模块级取值需在 _data_path 定义之后执行
|
||||||
VERSION = _config_val("version", "999908162319")
|
VERSION = _config_val("version", "999908162319")
|
||||||
_TLS_LOCAL_PORT = int(_config_val("local_port", 1080)) # 本地转发器监听端口
|
_TLS_LOCAL_PORT = int(_config_val("local_port", 1080)) # 本地转发器监听端口
|
||||||
PROXY_PORT = _TLS_LOCAL_PORT
|
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():
|
def _load_cn_ranges():
|
||||||
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
|
"""加载 pac_ip_cn.json 中的中国 IPv4 段,合并为整数区间供二分查找。"""
|
||||||
global _CN_RANGES, _CN_RANGES_LOADED
|
global _CN_RANGES, _CN_RANGES_LOADED
|
||||||
@@ -529,7 +660,7 @@ def _load_cn_ranges():
|
|||||||
_CN_RANGES_LOADED = True
|
_CN_RANGES_LOADED = True
|
||||||
try:
|
try:
|
||||||
import json, ipaddress
|
import json, ipaddress
|
||||||
path = _data_path("pac_ip_cn.json")
|
path = _user_path("pac_ip_cn.json")
|
||||||
if path is None:
|
if path is None:
|
||||||
log_red("未找到 pac_ip_cn.json,智能分流不可用(全部走隧道)")
|
log_red("未找到 pac_ip_cn.json,智能分流不可用(全部走隧道)")
|
||||||
return _CN_RANGES
|
return _CN_RANGES
|
||||||
@@ -563,7 +694,7 @@ def _load_force_proxy_suffixes():
|
|||||||
_FORCE_PROXY_LOADED = True
|
_FORCE_PROXY_LOADED = True
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
path = _data_path("pac_domains.json")
|
path = _user_path("pac_domains.json")
|
||||||
if path is None:
|
if path is None:
|
||||||
return _FORCE_PROXY_SUFFIXES # 可选文件,缺失不影响 IP 分流
|
return _FORCE_PROXY_SUFFIXES # 可选文件,缺失不影响 IP 分流
|
||||||
data = json.loads(Path(path).read_text("utf-8"))
|
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._update_download_done.connect(self._on_update_download_done)
|
||||||
self._status_ready.connect(self._apply_status)
|
self._status_ready.connect(self._apply_status)
|
||||||
|
|
||||||
|
_user_init_local() # 首启初始化 ~/RRPP(节点模板),须在 init_ui 读节点之前
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
|
||||||
# 每 2 秒刷新一次状态指示灯
|
# 每 2 秒刷新一次状态指示灯
|
||||||
@@ -974,6 +1107,8 @@ class MainWindow(QWidget):
|
|||||||
|
|
||||||
# 启动时后台检查更新,完成前禁用开启按钮
|
# 启动时后台检查更新,完成前禁用开启按钮
|
||||||
threading.Thread(target=self._check_update, daemon=True).start()
|
threading.Thread(target=self._check_update, daemon=True).start()
|
||||||
|
# 后台检查 PAC 数据更新(首次启动自动拉取到 ~/RRPP)
|
||||||
|
threading.Thread(target=_user_init_remote, daemon=True).start()
|
||||||
|
|
||||||
def init_ui(self):
|
def init_ui(self):
|
||||||
"""初始化主界面布局与样式。"""
|
"""初始化主界面布局与样式。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user