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): """初始化主界面布局与样式。"""