diff --git a/.gitignore b/.gitignore
index 7a60b85..6def320 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,13 @@
__pycache__/
*.pyc
+
+# Local development state and reproducible build outputs.
+.claude/
+lite/.claude/
+lite/backups/
+lite/build/
+lite/dist/
+
+# Runtime PAC downloads generated by the UI smoke test.
+lite/test/pac_direct_domains.json
+lite/test/pac_domains.json
diff --git a/lite/MODULE_SPLIT_PLAN.md b/lite/MODULE_SPLIT_PLAN.md
new file mode 100644
index 0000000..06db1d5
--- /dev/null
+++ b/lite/MODULE_SPLIT_PLAN.md
@@ -0,0 +1,148 @@
+# RRPP Lite 模块拆分计划
+
+## 目标与边界
+
+将当前 `main.py`(约 3,480 行)渐进拆成职责清晰、可独立测试的模块,同时保持以下行为不变:
+
+- PAC、全局代理、智能分流和本地 TLS 转发的现有接口与优先级;
+- 节点订阅、DPAPI 加密存储、登录及本地运行数据位置;
+- 源码运行、PyInstaller 单文件版和 Nuitka 版的启动方式;
+- 现有 `lite/test/test_pac_routing.py` 的全部回归用例。
+
+本轮拆分期间**不提交、不推送到主仓库、不打包发布**。每个阶段均只在本地工作区验证;出现回归时优先回退到拆分前备份。
+
+## 开始前的保护措施
+
+1. 记录当前 Git 状态与当前 HEAD,不触碰既有未跟踪的构建产物、备份和用户文件。
+2. 在 `lite/backups/` 创建带时间戳的源码备份,例如:
+ `proxy_lite.py.bak_YYYYMMDD_HHMMSS_before_module_split`。
+3. 首次拆分前运行一次完整基线:
+
+ ```powershell
+ wsl.exe -e bash -lc "cd '/mnt/d/Royirin Rosmontis Private Proxy' && python3 -m py_compile lite/main.py lite/test/test_pac_routing.py && python3 lite/test/test_pac_routing.py"
+ ```
+
+4. 每完成一个阶段,先做编译检查,再跑完整 PAC 回归;不通过则在进入下一阶段前修复或回退。
+
+## 当前实施进度(2026-09-16)
+
+- [x] 已创建拆分前备份:`lite/backups/proxy_lite.py.bak_20260916_220240_before_module_split`。
+- [x] 已完成一次拆分前基线与多次拆分后全量 PAC 回归。
+- [x] UI 主题、控件和三个对话框已迁入 `rrpp_ui/theme.py`、`rrpp_ui/widgets.py`、
+ `rrpp_ui/dialogs.py`;通过显式服务对象调用业务层,不反向导入入口文件。
+- [x] 已迁入核心包的无状态功能:CIDR/Trie 与 PAC 规则解析、PAC 拉取和原子写入、
+ `settings.json` 原子存储、DPAPI 节点/凭据存储、HTTP 转发报文解析与认证注入、
+ DNS/私网判断与 TTL 缓存、流量统计、节点认证请求协议。
+- [ ] 节点同步服务、带缓存的完整路由决策、转发器 socket 生命周期、系统代理注册表控制
+ 和主窗口仍在入口文件,待后续阶段继续迁移。
+- [x] 已获得用户授权,在完成构建与验证后执行 `git commit`、`git push` 和 Release 发布。
+
+## 目标目录
+
+```text
+lite/
+├─ main.py # 轻量入口、组装依赖、main
+├─ rrpp_core/
+│ ├─ __init__.py
+│ ├─ runtime.py # 路径、常量、日志与共享运行时上下文
+│ ├─ config_store.py # 普通配置、节点、DPAPI 凭据
+│ ├─ pac_data.py # PAC 在线数据下载、校验、缓存失效
+│ ├─ routing.py # 域名/IP 分流、白名单、CIDR/Trie
+│ ├─ forwarder.py # 本地 TLS 转发、HTTP CONNECT、认证注入
+│ └─ system_proxy.py # PAC 服务、Windows 系统代理状态
+├─ rrpp_ui/
+│ ├─ __init__.py
+│ ├─ theme.py # UI 色彩与样式常量
+│ ├─ widgets.py # NodeLabel、开关、状态点等可复用控件
+│ └─ dialogs.py # 登录、白名单、设置对话框
+└─ test/
+ └─ test_pac_routing.py
+```
+
+目录是目标形态,而不是一次性迁移要求。入口已统一为 `main.py`;测试和构建脚本均以该文件为准。
+
+## 分阶段实施
+
+### 阶段 0:基线与备份
+
+- 创建源码备份并保存当前测试输出。
+- 核验当前核心测试:PAC 规则优先级、IPv4/IPv6、私网、CONNECT 解析、认证注入、启动连通性校验及其取消流程。
+- 产物:可随时恢复的单文件版本;无业务代码变更。
+
+### 阶段 1:抽取无状态 UI 组件
+
+- 将 `NodeLabel`、`ToggleSwitch`、`StatusDot` 及图标辅助逻辑移到 `rrpp_ui/widgets.py`。
+- 主窗口继续从原入口导入/调用,保持控件信号、样式和构造参数不变。
+- 风险低:不涉及代理线程、注册表或路由决策。
+- 验收:`py_compile`、PAC 全量测试、一次 Windows GUI 手工启动。
+
+### 阶段 2:建立共享运行时与配置存储
+
+- 抽取路径常量、配置读写、节点读写、DPAPI 加解密、认证保存和订阅节点同步至 `rrpp_core/runtime.py`、`config_store.py`。
+- 明确依赖方向:模块不得反向导入 UI;日志通过注入函数或统一运行时上下文提供。
+- 保持 `%LOCALAPPDATA%\\RRPP`、配置文件名、加密格式和节点选择恢复逻辑完全兼容。
+- 风险中等:涉及用户已有数据;只做机械迁移,不改变存储格式。
+- 验收:现有节点可读取;登录/同步至少做一次人工冒烟;全部自动测试通过。
+
+### 阶段 3:抽取 PAC 数据与路由引擎
+
+- 将 PAC 数据下载/校验/写入和缓存失效移至 `rrpp_core/pac_data.py`。
+- 将白名单、强制代理、明确直连域名、CIDR、IPv6、DNS 解析、Trie 匹配和直连决策移至 `rrpp_core/routing.py`。
+- 使用显式的 `RoutingContext` 或等价配置对象替代跨模块全局变量;规则优先级必须锁定为:
+
+ ```text
+ 用户白名单 > 强制代理 > 明确直连域名 > LAN/私网 > GeoIP 智能分流
+ ```
+
+- 风险最高的核心阶段;禁止顺便修改规则语义或远程 PAC 格式。
+- 验收:扩充并运行分流回归测试,覆盖 Google/OpenAI/Anthropic、国内直连、IPv6、无 DNS 结果与缓存刷新。
+
+### 阶段 4:抽取转发器与系统代理控制
+
+- 将流量统计、本地 TLS 监听、CONNECT/绝对 URL 解析、认证注入与连接回退移至 `rrpp_core/forwarder.py`。
+- 将 PAC HTTP 服务、注册表备份/恢复、全局/PAC 开关和陈旧状态清理移至 `rrpp_core/system_proxy.py`。
+- 以明确的 start/stop 生命周期对象管理线程与端口,避免模块级状态互相覆盖。
+- 风险中高:涉及注册表和后台线程;本阶段不改变任何系统代理策略。
+- 验收:本地端口监听、PAC 开关、全局开关、取消启动、退出清理和重复启停的人工测试;完整回归测试。
+
+### 阶段 5:抽取对话框与收敛入口
+
+- 将 `LoginDialog`、`WhitelistDialog`、`SettingsDialog` 移至 `rrpp_ui/dialogs.py`,`MainWindow` 可保留在入口或迁到 `rrpp_ui/main_window.py`。
+- `main.py` 收敛为:初始化 DPI/单实例、创建运行时、装配 UI、调用 `main()`。
+- 保留必要的兼容导出,待构建脚本和测试完全迁移后再移除。
+- 验收:源码 GUI 手工走查、全量自动测试、PyInstaller/Nuitka 各做一次仅本地的构建与启动冒烟。
+
+### 阶段 6:清理与交付前评审
+
+- 删除已确认无引用的兼容层,补齐模块文档和最小单元测试。
+- 检查循环导入、隐式全局状态、未关闭线程、路径在冻结环境下的差异。
+- 对比拆分前后的 PAC 输出和系统代理状态变更。
+- 仅在用户确认后,另行决定提交、推送、打包和发布;它们不属于本次拆分的自动后续动作。
+
+## 依赖与架构约束
+
+```text
+main.py → rrpp_ui → rrpp_core
+ │ │
+ └───────────┘(仅通过显式上下文/接口协作)
+```
+
+- `rrpp_core` 不依赖 PyQt;这样 PAC 与转发逻辑可在无 GUI 环境测试。
+- UI 不直接读写注册表、节点文件或 socket;统一通过核心服务对象操作。
+- 禁止新的模块级可变全局状态,已有状态在迁移时集中到上下文或生命周期对象。
+- 配置、加密和 PAC 文件的文件名、字段、URL 与优先级,在本次重构内视为兼容性契约。
+
+## 验收清单
+
+- [x] 源码 `py_compile` 通过。
+- [x] `test_pac_routing.py` 全部通过。
+- [ ] PAC 启动/取消、全局模式、退出恢复系统代理均可用。
+- [ ] 既有节点和已加密凭据可读取,订阅同步不回退格式。
+- [ ] `chatgpt.com`、`auth.openai.com`、`claude.ai`、Google CDN 仍命中强制代理;国内显式直连规则仍生效。
+- [x] PyInstaller 与 Nuitka 均已完成本地构建;Release 附件将使用版本化文件名和 SHA-256 校验。
+- [x] 发布暂存仅包含本轮源码、测试、构建脚本、图标和模板;构建产物、账号数据和备份文件已排除。
+- [ ] 待提交、推送并上传 Release 附件。
+
+## 回滚策略
+
+任一阶段出现无法在当前阶段定位的回归时:停止继续迁移,将该阶段涉及文件恢复到阶段开始前的本地备份或 Git 基线;保留失败现场的测试输出以便定位。不得通过覆盖用户的运行配置、订阅或构建目录来“修复”回归。
diff --git a/lite/RRPP_Lite.spec b/lite/RRPP_Lite.spec
new file mode 100644
index 0000000..fe31960
--- /dev/null
+++ b/lite/RRPP_Lite.spec
@@ -0,0 +1,85 @@
+# -*- mode: python ; coding: utf-8 -*-
+
+from pathlib import Path
+from PyInstaller.utils.hooks import collect_all
+
+# 强制收集 PyQt5 全部模块及二进制文件
+qt_datas, qt_binaries, qt_hiddenimports = collect_all('PyQt5')
+
+# 只内置 config.json(普通设置)。nodes.json / pac_*.json 属用户数据:
+# 运行时读 %LOCALAPPDATA%\RRPP(首启自动创建模板 / 从 CDN 拉取),不进 EXE。
+_JSON_DATAS = [(str(Path(SPECPATH) / 'config.json'), '.')]
+print(f"[spec] 打包 json 数据文件: {[Path(src).name for src, _ in _JSON_DATAS]}")
+
+# 自定义 manifest:普通权限运行 + 声明 PerMonitorV2 DPI 感知,
+# 避免程序在不同缩放比例的显示器之间移动时被 Windows 位图拉伸。
+_MANIFEST = r'''
+
+
+ Royirin Rosmontis Private Proxy Lite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PerMonitorV2, PerMonitor, system
+ true/pm
+
+
+
+
+
+
+
+
+'''
+
+a = Analysis(
+ ['main.py'],
+ pathex=['..\\.venv\\Lib\\site-packages'],
+ binaries=qt_binaries,
+ datas=[('ico.ico', '.')] + _JSON_DATAS + qt_datas,
+ hiddenimports=qt_hiddenimports,
+ hookspath=[],
+ hooksconfig={},
+ runtime_hooks=[],
+ # 排除 Nuitka 构建链相关模块(venv 里装了 Nuitka,但 PyInstaller 产物不需要)
+ excludes=['nuitka', 'zstandard', 'ordered_set'],
+ noarchive=False,
+ optimize=0,
+)
+pyz = PYZ(a.pure)
+
+exe = EXE(
+ pyz,
+ a.scripts,
+ a.binaries,
+ a.datas,
+ [],
+ name='Royirin Rosmontis Private Proxy Lite Python',
+ debug=False,
+ bootloader_ignore_signals=False,
+ strip=False,
+ # UPX 会压缩资源段(含图标),冷缓存下 Explorer 可能提取不到图标导致任务栏空白
+ upx=False,
+ upx_exclude=[],
+ runtime_tmpdir=None,
+ console=False,
+ disable_windowed_traceback=False,
+ argv_emulation=False,
+ target_arch=None,
+ codesign_identity=None,
+ entitlements_file=None,
+ icon=['ico.ico'],
+ manifest=_MANIFEST,
+)
diff --git a/lite/build_lite.bat b/lite/build_lite.bat
new file mode 100644
index 0000000..47aa557
--- /dev/null
+++ b/lite/build_lite.bat
@@ -0,0 +1,19 @@
+@echo off
+cd /d "%~dp0"
+call ..\.venv\Scripts\python.exe -m PyInstaller --version >nul 2>&1
+if errorlevel 1 (
+ echo PyInstaller not installed. Run first:
+ echo ..\.venv\Scripts\python.exe -m pip install pyinstaller
+ pause
+ exit /b 1
+)
+call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec --noconfirm
+if errorlevel 1 (
+ echo.
+ echo Build failed.
+ pause
+ exit /b 1
+)
+echo.
+echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite Python.exe
+pause
diff --git a/lite/build_nuitka.bat b/lite/build_nuitka.bat
new file mode 100644
index 0000000..bce5563
--- /dev/null
+++ b/lite/build_nuitka.bat
@@ -0,0 +1,42 @@
+@echo off
+cd /d "%~dp0"
+call ..\.venv\Scripts\python.exe -m nuitka --version >nul 2>&1
+if errorlevel 1 (
+ echo Nuitka not installed. Run first:
+ echo ..\.venv\Scripts\python.exe -m pip install nuitka zstandard
+ pause
+ exit /b 1
+)
+rem Remove prior artifacts and the SCons incremental state before every build.
+rem The constants blob is embedded outside normal dependency tracking, so reuse
+rem can leave new UI strings and settings keys out of the executable.
+rd /s /q "dist\main.build" 2>nul
+rd /s /q "dist\main.dist" 2>nul
+rd /s /q "dist\main.onefile-build" 2>nul
+rem Use Nuitka COFF objects for embedded constants to avoid compiler cache reuse.
+rem Zig C23 embed caches do not reliably track embedded blob changes.
+set NUITKA_RESOURCE_MODE=coff_obj
+rem Convert config.json version to Windows file-version segments (each <= 65535).
+for /f %%v in ('..\.venv\Scripts\python.exe -c "import json;v=json.load(open('config.json',encoding='utf-8'))['version'];print(f'9999.{int(v[4:8])}.{int(v[8:12])}.0')"') do set "FILEVER=%%v"
+call ..\.venv\Scripts\python.exe -m nuitka ^
+ --standalone --onefile ^
+ --enable-plugin=pyqt5 ^
+ --include-data-files=config.json=config.json ^
+ --windows-icon-from-ico=ico.ico ^
+ --windows-console-mode=disable ^
+ --company-name="Global Rosmanka Cloud Nexus Inc" ^
+ --product-name="R.R.P.P." ^
+ --file-version=%FILEVER% ^
+ --output-dir=dist ^
+ --output-filename="Royirin Rosmontis Private Proxy Lite.exe" ^
+ --assume-yes-for-downloads ^
+ main.py
+if errorlevel 1 (
+ echo.
+ echo Build failed.
+ pause
+ exit /b 1
+)
+echo.
+echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe
+pause
diff --git a/lite/build_pyinstaller.bat b/lite/build_pyinstaller.bat
new file mode 100644
index 0000000..c2f6137
--- /dev/null
+++ b/lite/build_pyinstaller.bat
@@ -0,0 +1,12 @@
+@echo off
+cd /d "%~dp0"
+call ..\.venv\Scripts\python.exe -m PyInstaller RRPP_Lite.spec
+if errorlevel 1 (
+ echo.
+ echo Build failed.
+ pause
+ exit /b 1
+)
+echo.
+echo Build complete. Output: dist\Royirin Rosmontis Private Proxy Lite.exe
+pause
diff --git a/lite/claude.bat b/lite/claude.bat
new file mode 100644
index 0000000..1439940
--- /dev/null
+++ b/lite/claude.bat
@@ -0,0 +1,12 @@
+@echo off
+setlocal
+rem Convert current Windows dir to a WSL path (lowercase drive + forward slashes)
+set "WSLDIR=%CD:\=/%"
+set "WSLDIR=%WSLDIR::=%"
+set "DRIVE=%WSLDIR:~0,1%"
+for %%C in (a b c d e f g h i j k l m n o p q r s t u v w x y z) do if /i "%%C"=="%DRIVE%" set "WSLDIR=%%C%WSLDIR:~1%"
+set "WSLDIR=/mnt/%WSLDIR%"
+rem -i is essential: in a non-interactive shell PS1 is empty, so ~/.bashrc's
+rem interactive guard returns early and the model-mapping env vars are lost
+wsl -e bash -ic "cd '%WSLDIR%' && /root/.local/bin/claude --continue"
+pause
diff --git a/lite/ico.ico b/lite/ico.ico
new file mode 100644
index 0000000..f2d1a18
Binary files /dev/null and b/lite/ico.ico differ
diff --git a/lite/proxy_lite.py b/lite/main.py
similarity index 72%
rename from lite/proxy_lite.py
rename to lite/main.py
index 3109f62..31c7965 100644
--- a/lite/proxy_lite.py
+++ b/lite/main.py
@@ -6,28 +6,57 @@ from pathlib import Path
from PyQt5.QtWidgets import (
QApplication, QWidget, QLabel, QPushButton,
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
- QCheckBox, QMenu, QDialog, QLineEdit,
- QListWidget, QListWidgetItem,
+ QCheckBox, QMenu, QDialog,
+)
+from PyQt5.QtCore import Qt, QTimer, pyqtSignal
+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,
+ WhitelistDialog, WhitelistServices,
+)
+from rrpp_core.routing_primitives import (
+ build_suffix_trie as _build_suffix_trie,
+ merge_ip_ranges as _merge_ip_ranges,
+ parse_domain_rules as _parse_domain_rules,
+ suffix_trie_matches as _suffix_trie_matches,
+)
+from rrpp_core.settings_store import JsonSettingsStore
+from rrpp_core.pac_data import (
+ atomic_write as _pac_atomic_write,
+ fetch_json as _pac_fetch_json,
+ no_proxy_opener as _pac_no_proxy_opener,
+ read_version as _pac_read_version,
+ validate_document as _pac_validate,
+)
+from rrpp_core.forwarder_primitives import (
+ force_http_close as _force_http_close,
+ inject_proxy_auth as _inject_proxy_auth_value,
+ parse_connect_authority as _parse_connect_authority,
+ parse_http_absolute as _parse_http_absolute,
+ starts_with_http_method as _starts_with_method,
+)
+from rrpp_core.node_secrets import NodeSecretsStore
+from rrpp_core.traffic import TrafficStats, format_bytes as _fmt_bytes
+from rrpp_core.node_api import (
+ NodeAuthError as _AuthError,
+ b64url as _b64url,
+ fetch_nodes_auth as _fetch_nodes_auth,
+)
+from rrpp_core.network_primitives import (
+ is_private_host as _is_private_host,
+ is_private_ipv4_int as _is_private_int,
+ resolve_host_ips as _resolve_host_ips,
+ ttl_cache as _ttl_cache,
)
-from PyQt5.QtCore import Qt, QTimer, QRectF, pyqtSignal
-from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor, QPainter
# GUI 窗口在 96 DPI / 100% 缩放下的逻辑尺寸;启用 Qt 高分屏缩放后,
# Qt 会自动按 devicePixelRatio 放大/缩小,因此代码里保持逻辑尺寸不变即可。
_BASE_WINDOW_WIDTH = 340
_BASE_WINDOW_HEIGHT = 470 # 内容区 + 加大日志区 + 底部两行提示小字
-# ── 配色(Qt StyleSheet 使用)────────────────────────────
-C_TITLE = "#8899aa" # 标题 / 副标题
-C_GREEN = "#51cf66" # 开启按钮、已连接状态
-C_YELLOW = "#ffd43b" # 提示色(保留)
-C_RED = "#ff6b6b" # 关闭按钮、未连接 / 错误状态
-C_BG = "#ffffff" # 主背景
-C_CARD = "#f0f2f5" # 卡片背景
-C_BORDER = "#d0d5dd" # 边框
-C_TEXT = "#333333" # 正文
-C_SUBTEXT = "#889099" # 次要文字
-
# ── 路径 ──────────────────────────────────────────────
# 已被打包为 EXE(PyInstaller 的 sys.frozen / Nuitka 的 __compiled__):
# BASE_DIR -> 内置数据目录:PyInstaller 用 _MEIPASS,Nuitka onefile 用
@@ -653,50 +682,19 @@ def _verify_proxy_connectivity(timeout=10, opener=None, cancel_event=None):
# ── 流量统计 ──────────────────────────────────────────
# 所有代理流量都经过本地转发器(直连 / PAC 均如此),转发器里计数即可覆盖全部模式;
# 每次"开启代理"时清零,即"本次连接"流量。
-_traffic_lock = threading.Lock()
-_traffic = {"up": 0, "down": 0}
-_traffic_prev = {"up": 0, "down": 0, "ts": 0.0}
+_TRAFFIC = TrafficStats()
def _traffic_add(up=0, down=0):
- with _traffic_lock:
- _traffic["up"] += up
- _traffic["down"] += down
+ _TRAFFIC.add(up=up, down=down)
def _traffic_reset():
- with _traffic_lock:
- _traffic["up"] = _traffic["down"] = 0
- _traffic_prev["up"] = _traffic_prev["down"] = 0
- _traffic_prev["ts"] = time.time()
-
-def _fmt_bytes(n):
- f = float(n)
- for unit in ("B", "KB", "MB", "GB", "TB"):
- if f < 1024 or unit == "TB":
- return f"{int(f)} {unit}" if unit == "B" else f"{f:.1f} {unit}"
- f /= 1024
+ _TRAFFIC.reset()
def _traffic_rate():
- with _traffic_lock:
- now = time.time()
- up_total = _traffic["up"]
- down_total = _traffic["down"]
- prev_up = _traffic_prev["up"]
- prev_down = _traffic_prev["down"]
- prev_ts = _traffic_prev["ts"]
- if prev_ts > 0 and now > prev_ts:
- dt = now - prev_ts
- up_rate = int((up_total - prev_up) / dt)
- down_rate = int((down_total - prev_down) / dt)
- else:
- up_rate = down_rate = 0
- _traffic_prev["up"] = up_total
- _traffic_prev["down"] = down_total
- _traffic_prev["ts"] = now
- return up_rate, down_rate, up_total, down_total
+ return _TRAFFIC.rate()
def _traffic_snapshot():
- with _traffic_lock:
- return _traffic["up"], _traffic["down"]
+ return _TRAFFIC.snapshot()
def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False, quiet=False):
"""启动本地认证转发器:127.0.0.1: -> remote_host:remote_port(可选 TLS 包裹)。
@@ -750,12 +748,6 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
if not quiet:
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})")
-_HTTP_METHODS = (b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
- b"HEAD ", b"OPTIONS ", b"PATCH ")
-
-def _starts_with_method(data: bytes) -> bool:
- return any(data.startswith(m) for m in _HTTP_METHODS)
-
def _inject_proxy_auth(data: bytes) -> bytes:
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
@@ -766,23 +758,7 @@ def _inject_proxy_auth(data: bytes) -> bytes:
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
保证上游(取首个 Authorization)读到的是我们的认证。
"""
- if not _starts_with_method(data):
- return data
- end = data.find(b"\r\n\r\n")
- if end == -1:
- return data
- head = data[:end]
- first_end = head.find(b"\r\n")
- request_line = head if first_end == -1 else head[:first_end]
- headers = b"" if first_end == -1 else head[first_end + 2:]
- for line in headers.split(b"\r\n"):
- if b":" not in line:
- continue
- name = line[:line.index(b":")].strip().lower()
- if name == b"authorization":
- return data # 已有目标站自己的 Authorization,不再注入
- auth_line = f"Authorization: Basic {_proxy_auth_basic()}\r\n".encode()
- return request_line + b"\r\n" + auth_line + headers + data[end:]
+ return _inject_proxy_auth_value(data, _proxy_auth_basic())
def _make_request_injector():
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
@@ -805,67 +781,10 @@ def _make_request_injector():
return b""
return feed
-def _ttl_cache(seconds: int, maxsize: int = 16384):
- """带过期时间的缓存装饰器(替代无 TTL 的 lru_cache)。
-
- 域名分流判定结果长期缓存会让域名换 IP 后继续沿用旧的直连/隧道判断;
- 到期自动失效重查。保留 cache_clear() 兼容(测试脚本在用)。"""
- def deco(func):
- store = {}
- lock = threading.Lock()
-
- def wrapper(*args, **kwargs):
- key = (args, tuple(sorted(kwargs.items())))
- now = time.time()
- with lock:
- hit = store.get(key)
- if hit and now - hit[0] < seconds:
- return hit[1]
- val = func(*args, **kwargs)
- with lock:
- store[key] = (now, val)
- if len(store) > maxsize:
- cutoff = now - seconds
- for k, (t, _) in list(store.items()):
- if t < cutoff:
- del store[k]
- if len(store) > maxsize:
- store.clear() # 极端情况下全清:最坏只是多几次解析
- return val
-
- def cache_clear():
- with lock:
- store.clear()
-
- wrapper.cache_clear = cache_clear
- return wrapper
- return deco
-
@_ttl_cache(300, maxsize=2048)
def _is_private_ip(host: str) -> bool:
"""判断目标是否命中常用内网、回环或链路本地地址(IPv4/IPv6)。"""
- try:
- import ipaddress
- ip = ipaddress.ip_address(host)
- addresses = [ip]
- except ValueError:
- try:
- import socket
- addresses = []
- for addr in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM):
- try:
- addresses.append(ipaddress.ip_address(addr[4][0]))
- except ValueError:
- pass
- except Exception:
- return False
- for ip in addresses:
- if ip.version == 4 and _is_private_int(int(ip)):
- return True
- if ip.version == 6 and (ip.is_loopback or ip.is_link_local
- or ip in ipaddress.ip_network("fc00::/7")):
- return True
- return False
+ return _is_private_host(host)
# ── IP 智能分流(域名表优先,非中国 IP 走隧道,中国 IP 直连) ─────
@@ -898,6 +817,7 @@ def _data_path(name: str):
# 非 Windows 环境回退用户主目录;测试可覆盖
_USER_DIR = (Path(os.environ["LOCALAPPDATA"]) if os.name == "nt" and os.environ.get("LOCALAPPDATA")
else Path.home()) / "RRPP"
+_NODE_SECRETS = NodeSecretsStore(lambda: _USER_DIR)
def _user_path(name: str):
"""用户数据文件路径:dev 模式(未打包)源码目录优先,方便本地测试;
@@ -916,25 +836,15 @@ def _user_path(name: str):
def _settings_path():
return _USER_DIR / "settings.json"
+_SETTINGS_STORE = JsonSettingsStore(_settings_path)
+
def _settings_load():
"""读取本地设置;缺失或损坏返回空字典。"""
- try:
- import json
- f = _settings_path()
- if not f.is_file():
- return {}
- data = json.loads(f.read_text("utf-8"))
- return data if isinstance(data, dict) else {}
- except Exception:
- return {}
+ return _SETTINGS_STORE.load()
def _settings_save(data):
"""本地设置原子写回。"""
- import json
- _USER_DIR.mkdir(parents=True, exist_ok=True)
- tmp = _settings_path().with_suffix(".json.tmp")
- tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8")
- os.replace(tmp, _settings_path())
+ _SETTINGS_STORE.save(data)
def _parse_port(text):
"""解析端口号;非法(非数字或超出 1-65535)返回 None。"""
@@ -1083,45 +993,9 @@ PROXY_PORT = _TLS_LOCAL_PORT
# {pac_base_url}/pac_ip_cn.json 中国 IP 段(同上)
_PAC_FILES = ("pac_domains", "pac_direct_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 in ("pac_domains", "pac_direct_domains"):
- return isinstance(data.get("domains"), list)
- if name == "pac_ip_cn":
- return isinstance(data.get("cidrs"), list)
- return False
-
def _pac_write(name, data):
"""PAC 数据原子写入用户数据目录(tmp+replace,写不坏旧文件)。"""
- import 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")
+ _pac_atomic_write(_USER_DIR, name, data)
def _invalidate_pac_routing_cache(name=None):
@@ -1151,96 +1025,28 @@ def _invalidate_pac_routing_cache(name=None):
# ── nodes.json 加密存储(Windows DPAPI,本机本用户可解) ─────────────
# 非 Windows(dev/测试)不加密,读写明文;读取对密文/明文都兼容。
-class _DPAPI_BLOB(ctypes.Structure):
- _fields_ = [("cbData", ctypes.c_uint32),
- ("pbData", ctypes.POINTER(ctypes.c_char))]
-
-
def _dpapi_protect(data: bytes) -> bytes:
- """DPAPI 加密 CryptProtectData(无附加熵,用户+机器绑定)。"""
- crypt32 = ctypes.WinDLL("crypt32")
- kernel32 = ctypes.WinDLL("kernel32")
- crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.c_wchar_p,
- ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p,
- ctypes.c_void_p, ctypes.c_uint32,
- ctypes.POINTER(_DPAPI_BLOB)]
- crypt32.CryptProtectData.restype = ctypes.c_bool
- kernel32.LocalFree.argtypes = [ctypes.c_void_p]
-
- buf = ctypes.create_string_buffer(data, len(data))
- blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
- blob_out = _DPAPI_BLOB()
- if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
- None, None, 0, ctypes.byref(blob_out)):
- raise OSError("CryptProtectData 调用失败")
- try:
- return ctypes.string_at(blob_out.pbData, blob_out.cbData)
- finally:
- kernel32.LocalFree(blob_out.pbData)
+ return _NODE_SECRETS.protect_bytes(data)
def _dpapi_unprotect(data: bytes) -> bytes:
- """DPAPI 解密 CryptUnprotectData。"""
- crypt32 = ctypes.WinDLL("crypt32")
- kernel32 = ctypes.WinDLL("kernel32")
- crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DPAPI_BLOB), ctypes.POINTER(ctypes.c_wchar_p),
- ctypes.POINTER(_DPAPI_BLOB), ctypes.c_void_p,
- ctypes.c_void_p, ctypes.c_uint32,
- ctypes.POINTER(_DPAPI_BLOB)]
- crypt32.CryptUnprotectData.restype = ctypes.c_bool
- kernel32.LocalFree.argtypes = [ctypes.c_void_p]
-
- buf = ctypes.create_string_buffer(data, len(data))
- blob_in = _DPAPI_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
- descr = ctypes.c_wchar_p()
- blob_out = _DPAPI_BLOB()
- if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None,
- None, None, 0, ctypes.byref(blob_out)):
- raise OSError("CryptUnprotectData 调用失败")
- try:
- return ctypes.string_at(blob_out.pbData, blob_out.cbData)
- finally:
- kernel32.LocalFree(blob_out.pbData)
- if descr.value:
- kernel32.LocalFree(descr)
+ return _NODE_SECRETS.unprotect_bytes(data)
def _nodes_encrypt(text: str) -> bytes:
- """加密 nodes.json 内容;非 Windows 环境明文(dev/测试)。"""
- if os.name != "nt":
- return text.encode("utf-8")
- return _dpapi_protect(text.encode("utf-8"))
+ return _NODE_SECRETS.encrypt_text(text)
def _nodes_decrypt(blob: bytes) -> str:
- """解密 nodes.json;解密失败回退按明文解析(兼容旧文件与在线拉取写入的明文)。"""
- if os.name != "nt":
- return blob.decode("utf-8")
- try:
- return _dpapi_unprotect(blob).decode("utf-8")
- except Exception:
- return blob.decode("utf-8")
+ return _NODE_SECRETS.decrypt_bytes(blob)
+
def _auth_save(username, password):
- """登录凭据加密保存到用户数据目录(DPAPI,本机本用户可解)。"""
- import json
- text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
- _USER_DIR.mkdir(parents=True, exist_ok=True)
- (_USER_DIR / "auth.json").write_bytes(_nodes_encrypt(text))
+ _NODE_SECRETS.save_auth(username, password)
+
def _auth_load():
- """读取已保存的登录凭据;不存在或损坏返回 None。"""
- try:
- import json
- f = _USER_DIR / "auth.json"
- if not f.is_file():
- return None
- data = json.loads(_nodes_decrypt(f.read_bytes()))
- if not (isinstance(data, dict) and data.get("username") and data.get("password")):
- return None
- return data
- except Exception:
- return None
+ return _NODE_SECRETS.load_auth()
def _nodes_sync_remote():
"""启动时用已保存凭据拉取最新节点配置(内容比对,变化才覆盖)。"""
@@ -1273,42 +1079,6 @@ def _nodes_sync_remote():
finally:
_NODES_SYNC_DONE.set() # 无论成败都要放行启动校验
-class _AuthError(Exception):
- """订阅接口返回的错误:code 为 HTTP 状态码,str 为服务端返回的中文提示。"""
-
- def __init__(self, msg, code=0):
- super().__init__(msg)
- self.code = code
-
-
-def _b64url(s: str) -> str:
- """SSO 认证查询参数的 URL-safe Base64 编码(末尾 padding 省略)。"""
- return base64.urlsafe_b64encode(str(s).encode("utf-8")).decode("ascii").rstrip("=")
-
-
-def _fetch_nodes_auth(url, user, password, timeout=10):
- """用 SSO 账号拉取 nodes.json:user/password 按 URL-safe Base64 作为查询参数
- 拼接在 nodes_url 后(旧版 nginx auth_basic 已废弃);直连,不经过系统代理。
- 失败抛 _AuthError(服务端拒绝,str 为服务端中文原因)或 urllib 异常(网络问题)。"""
- import json
- import urllib.request
- import urllib.error
- opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
- sep = "&" if "?" in url else "?"
- req_url = f"{url}{sep}user={_b64url(user)}&password={_b64url(password)}"
- try:
- with opener.open(req_url, timeout=timeout) as r:
- return json.loads(r.read().decode("utf-8"))
- except urllib.error.HTTPError as e:
- # 401/403/429/5xx:服务端 body 是面向用户的中文原因,直接透出
- body = ""
- try:
- body = e.read().decode("utf-8", "replace").strip()
- except Exception:
- pass
- raise _AuthError(body or f"请求被拒绝 (HTTP {e.code})", e.code) from None
-
-
def _user_init_local():
"""初始化用户数据目录(同步):建目录,nodes.json 缺失时写入加密空模板。"""
_USER_DIR.mkdir(parents=True, exist_ok=True)
@@ -1368,25 +1138,6 @@ def _user_init_remote(base_url=None):
if updated == 0 and failed == 0 and (_USER_DIR / "pac_domains.json").is_file():
log("PAC 数据已是最新")
-def _merge_ip_ranges(cidrs, version):
- """将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
- import ipaddress
- ranges = []
- for c in cidrs:
- net = ipaddress.ip_network(str(c).strip(), strict=False)
- if net.version == version:
- ranges.append((int(net.network_address), int(net.broadcast_address)))
- ranges.sort()
- merged = []
- for start, end in ranges:
- if merged and start <= merged[-1][1] + 1:
- if end > merged[-1][1]:
- merged[-1] = (merged[-1][0], end)
- else:
- merged.append((start, end))
- return merged
-
-
def _load_cn_ranges():
"""加载 pac_ip_cn.json 的中国 IPv4/IPv6 CIDR,合并为整数区间。"""
global _CN_RANGES, _CN_RANGES_V6, _CN_RANGES_LOADED
@@ -1434,37 +1185,12 @@ def _load_force_proxy_suffixes():
log(f"未找到 pac_domains.json,使用 {len(_FORCE_PROXY_SUFFIXES)} 条关键域名兜底")
return _FORCE_PROXY_SUFFIXES
data = json.loads(Path(path).read_text("utf-8"))
- suffixes = set(_CORE_FORCE_PROXY_SUFFIXES)
- exact = set()
- regexes = []
- invalid_regexes = 0
- for raw in data.get("domains") or []:
- raw_rule = str(raw).strip()
- rule = raw_rule.lower()
- if not raw_rule:
- continue
- if rule.startswith("full:"):
- host = rule[len("full:"):].rstrip(".")
- if host:
- exact.add(host)
- continue
- if rule.startswith("regexp:"):
- # 正则本身可能含大写字符类或大小写敏感字面量;只忽略匹配时的
- # 域名大小写,不能在编译前改写 pattern。
- pattern = raw_rule[len("regexp:"):]
- if not pattern:
- invalid_regexes += 1
- continue
- try:
- regexes.append(re.compile(pattern, re.IGNORECASE))
- except re.error:
- invalid_regexes += 1
- continue
- suffixes.add(rule.lstrip("*.").rstrip("."))
- suffixes.discard("")
+ suffixes, exact, regexes, invalid_regexes = _parse_domain_rules(
+ data.get("domains"), _CORE_FORCE_PROXY_SUFFIXES
+ )
_FORCE_PROXY_SUFFIXES = suffixes
- _FORCE_PROXY_EXACT = frozenset(exact)
- _FORCE_PROXY_REGEXES = tuple(regexes)
+ _FORCE_PROXY_EXACT = exact
+ _FORCE_PROXY_REGEXES = regexes
log(f"已加载强制代理规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、"
f"正则 {len(regexes)} 条 (pac_domains.json)" +
(f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else ""))
@@ -1478,28 +1204,6 @@ def _load_force_proxy_suffixes():
return _FORCE_PROXY_SUFFIXES
-def _build_suffix_trie(suffixes):
- """按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
- root = {}
- for suffix in suffixes:
- node = root
- for label in reversed(suffix.split(".")):
- node = node.setdefault(label, {})
- node[""] = True
- return root
-
-
-def _suffix_trie_matches(host: str, trie) -> bool:
- node = trie
- for label in reversed(host.rstrip(".").lower().split(".")):
- node = node.get(label)
- if node is None:
- return False
- if node.get(""):
- return True
- return bool(node and node.get(""))
-
-
def _load_direct_domains():
"""加载 pac_direct_domains.json:PAC 智能分流中明确直连的域名规则。"""
global _DIRECT_SUFFIX_TRIE, _DIRECT_EXACT, _DIRECT_REGEXES, _DIRECT_LOADED
@@ -1513,34 +1217,10 @@ def _load_direct_domains():
_DIRECT_SUFFIX_TRIE, _DIRECT_EXACT, _DIRECT_REGEXES = {}, frozenset(), ()
return _DIRECT_SUFFIX_TRIE
data = json.loads(Path(path).read_text("utf-8"))
- suffixes, exact, regexes = set(), set(), []
- invalid_regexes = 0
- for raw in data.get("domains") or []:
- raw_rule = str(raw).strip()
- rule = raw_rule.lower()
- if not raw_rule:
- continue
- if rule.startswith("full:"):
- host = rule[len("full:"):].rstrip(".")
- if host:
- exact.add(host)
- continue
- if rule.startswith("regexp:"):
- pattern = raw_rule[len("regexp:"):]
- if not pattern:
- invalid_regexes += 1
- continue
- try:
- regexes.append(re.compile(pattern, re.IGNORECASE))
- except re.error:
- invalid_regexes += 1
- continue
- suffix = rule.lstrip("*.").rstrip(".")
- if suffix:
- suffixes.add(suffix)
+ suffixes, exact, regexes, invalid_regexes = _parse_domain_rules(data.get("domains"))
_DIRECT_SUFFIX_TRIE = _build_suffix_trie(suffixes)
- _DIRECT_EXACT = frozenset(exact)
- _DIRECT_REGEXES = tuple(regexes)
+ _DIRECT_EXACT = exact
+ _DIRECT_REGEXES = regexes
log(f"已加载明确直连规则: 后缀 {len(suffixes)} 条、精确 {len(exact)} 条、"
f"正则 {len(regexes)} 条 (pac_direct_domains.json)" +
(f",跳过 {invalid_regexes} 条非法正则" if invalid_regexes else ""))
@@ -1552,14 +1232,6 @@ def _load_direct_domains():
return _DIRECT_SUFFIX_TRIE
-def _is_private_int(n: int) -> bool:
- """整数形式的 IPv4 是否属于常用内网/回环段。"""
- return ((n & 0xFF000000) == 0x0A000000 or
- (n & 0xFFF00000) == 0xAC100000 or
- (n & 0xFFFF0000) == 0xC0A80000 or
- (n & 0xFF000000) == 0x7F000000 or
- (n & 0xFFFF0000) == 0xA9FE0000)
-
@_ttl_cache(300, maxsize=16384)
def _resolve_china_direct_ip(host: str):
"""返回可安全直连的中国 IPv4/IPv6,或 None。
@@ -1644,21 +1316,6 @@ def _is_direct_domain(host: str) -> bool:
return (_suffix_trie_matches(h, trie) or h in exact or
any(pattern.search(h) is not None for pattern in regexes))
-def _resolve_host_ips(host: str):
- """解析域名的 IPv4/IPv6 地址集合(字面量 IP 返回自身);解析失败返回空集合。"""
- import ipaddress
- try:
- return {ipaddress.ip_address(host)}
- except ValueError:
- pass
- try:
- import socket
- # 双栈解析(AF_UNSPEC):IPv6 白名单条目也能命中域名解析出的 IPv6 地址
- return {ipaddress.ip_address(a[4][0])
- for a in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)}
- except Exception:
- return set()
-
def _whitelist_entry_match(host: str, entry: str) -> bool:
"""单条白名单规则匹配:IP/CIDR 条目按解析地址比对,域名条目按后缀匹配。"""
h = host.rstrip(".").lower()
@@ -1727,66 +1384,6 @@ def _is_smart_china_direct(host: str, bypass_lan: bool, smart: bool) -> bool:
and not (bypass_lan and _is_private_ip(host))
and _resolve_china_direct_ip(host) is not None)
-def _parse_connect_authority(first_line: str):
- """从 CONNECT 首行提取 (host, port),失败返回 None。
-
- IPv6 字面量带方括号(CONNECT [::1]:443):剥掉括号再按最后一个冒号拆分,
- 否则会把 "[::1]" 当成主机名,直连/白名单判定全部失效。"""
- parts = first_line.split(" ")
- if len(parts) < 2:
- return None
- authority = parts[1]
- if authority.startswith("[") and "]" in authority:
- # 括号必须紧跟在主机名部分之后([::1]:443);"[::1" 无右括号等畸形输入
- # 会因 "]:" 检查失败而走下面的普通拆分,最终被 port_s.isdigit() 拦下
- try:
- host = authority[1:authority.index("]")]
- tail = authority[authority.index("]") + 1:]
- if tail.startswith(":") and tail[1:].isdigit():
- return host, int(tail[1:])
- except ValueError:
- pass
- if ":" not in authority:
- return None
- host, _, port_s = authority.rpartition(":")
- if not host or not port_s.isdigit():
- return None
- return host, int(port_s)
-
-def _parse_http_absolute(first_line: str):
- """从明文 HTTP 代理首行提取 (host, port),失败返回 None。"""
- parts = first_line.split(" ")
- if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
- return None
- from urllib.parse import urlsplit
- parsed = urlsplit(parts[1])
- if not parsed.hostname:
- return None
- port = parsed.port or (443 if parsed.scheme == "https" else 80)
- return parsed.hostname, port
-
-def _force_http_close(buf: bytes) -> bytes:
- """直连明文 HTTP 时强制 Connection: close,避免 keep-alive 复用错连其他主机。"""
- end = buf.find(b"\r\n\r\n")
- if end == -1:
- return buf
- head, tail = buf[:end], buf[end:]
- lines = head.split(b"\r\n")
- out = [lines[0]]
- conn_seen = False
- for line in lines[1:]:
- low = line.lower()
- if low.startswith(b"connection:"):
- out.append(b"Connection: close")
- conn_seen = True
- elif low.startswith(b"proxy-connection:"):
- continue
- else:
- out.append(line)
- if not conn_seen:
- out.append(b"Connection: close")
- return b"\r\n".join(out) + tail
-
def _connect_route(direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls,
create_connection=None):
"""连接直连目标或上游隧道;智能中国直连失败时安全回退一次隧道。"""
@@ -1994,608 +1591,59 @@ def load_icon():
img.fill(QColor(C_TITLE))
return QIcon(QPixmap.fromImage(img))
-# ── 可点击节点标签 ─────────────────────────────────────
-_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
-_NL_HOVER = _NL_BASE + " text-decoration: underline;"
+def _login_services():
+ """为登录框提供明确的节点与凭据服务,UI 不直接依赖本模块全局变量。"""
+ return LoginServices(
+ load_icon=load_icon,
+ load_auth=_auth_load,
+ get_nodes_url=lambda: _config_val("nodes_url", ""),
+ fetch_nodes=_fetch_nodes_auth,
+ user_dir=lambda: _USER_DIR,
+ encrypt_nodes=_nodes_encrypt,
+ save_auth=_auth_save,
+ auth_error=_AuthError,
+ log=log,
+ )
-class NodeLabel(QLabel):
- """可点击标签:hover 显示下划线+手型光标,点击发出 clicked 信号。"""
- clicked = pyqtSignal()
- def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None):
- super().__init__(text)
- self._base = base or _NL_BASE
- self._hover = hover or self._base + " text-decoration: underline;"
- self.setStyleSheet(self._base)
- if tooltip:
- self.setToolTip(tooltip)
+def _whitelist_services():
+ return WhitelistServices(
+ load_icon=load_icon,
+ load_rules=_whitelist_rules,
+ validate_rule=_whitelist_validate_entry,
+ save_rules=_whitelist_save,
+ )
- def enterEvent(self, e):
- self.setStyleSheet(self._hover)
- self.setCursor(Qt.PointingHandCursor)
- super().enterEvent(e)
- def leaveEvent(self, e):
- self.setStyleSheet(self._base)
- self.unsetCursor()
- super().leaveEvent(e)
-
- def mouseReleaseEvent(self, e):
- if e.button() == Qt.LeftButton:
- self.clicked.emit()
- super().mouseReleaseEvent(e)
-
-# ── 节点配置登录框 ─────────────────────────────────────
-
-class LoginDialog(QDialog):
- """节点配置登录框:SSO 账号(邮箱+密码,base64url 查询参数)认证 nodes_url,
- 成功后拉取并加密保存 nodes.json。"""
- _done = pyqtSignal(bool, str)
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("节点配置登录")
- self.setWindowIcon(load_icon())
- self.setFixedSize(420, 170)
- 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: 6px 8px;
- color: {C_TEXT};
- font-size: 12px;
- }}
- QPushButton#btnOk {{
- background-color: {C_GREEN}; color: #fff; border: none;
- border-radius: 6px; padding: 4px 12px;
- font-size: 12px; font-weight: bold;
- }}
- QPushButton#btnOk:hover {{ background-color: #5ed978; }}
- QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }}
- QPushButton#btnCancel {{
- background-color: {C_CARD}; color: {C_SUBTEXT};
- border: 1px solid {C_BORDER}; border-radius: 6px;
- padding: 4px 12px; font-size: 12px;
- }}
- """)
-
- root = QHBoxLayout(self)
- root.setSpacing(14)
- root.setContentsMargins(20, 18, 20, 16)
-
- # 左侧 logo + 品牌小字
- left = QVBoxLayout()
- left.setSpacing(6)
- left.addStretch()
- logo = QLabel()
- logo.setPixmap(load_icon().pixmap(80, 80))
- logo.setFixedSize(80, 80)
- logo.setAlignment(Qt.AlignCenter)
- left.addWidget(logo, 0, Qt.AlignHCenter)
- brand = QLabel("R.R.P.P.")
- brand.setStyleSheet(f"color: {C_TITLE}; font-size: 10px;")
- brand.setAlignment(Qt.AlignCenter)
- left.addWidget(brand, 0, Qt.AlignHCenter)
- left.addStretch()
- root.addLayout(left)
-
- # 右侧表单(外层容器按内容高度垂直居中,避免被横向布局拉伸出大段留白)
- form = QVBoxLayout()
- form.setSpacing(6)
- self.ed_user = QLineEdit()
- self.ed_user.setPlaceholderText("SSO 邮箱")
- form.addWidget(self.ed_user)
-
- self.ed_pass = QLineEdit()
- self.ed_pass.setPlaceholderText("密码")
- self.ed_pass.setEchoMode(QLineEdit.Password)
- self.ed_pass.returnPressed.connect(self._submit)
- form.addWidget(self.ed_pass)
-
- # 保存过凭据时预填,重新登录只需点一下
- cred = _auth_load()
- if cred:
- self.ed_user.setText(cred["username"])
- self.ed_pass.setText(cred["password"])
-
- self.lb_err = QLabel("")
- self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
- self.lb_err.setFixedHeight(14)
- self.lb_err.hide() # 无错误时不占空间,避免密码框下方留大片空白
- form.addWidget(self.lb_err)
-
- bottom = QHBoxLayout()
- bottom.setSpacing(10)
- hint = QLabel("登录以获取节点配置")
- hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
- bottom.addWidget(hint)
- bottom.addStretch()
- self.btn_cancel = QPushButton("取消")
- self.btn_cancel.setObjectName("btnCancel")
- self.btn_cancel.clicked.connect(self.reject)
- bottom.addWidget(self.btn_cancel)
- self.btn_ok = QPushButton("登录")
- self.btn_ok.setObjectName("btnOk")
- self.btn_ok.clicked.connect(self._submit)
- bottom.addWidget(self.btn_ok)
- form.addLayout(bottom)
- box = QWidget()
- box.setLayout(form)
- root.addWidget(box, 1, Qt.AlignVCenter)
-
- self._done.connect(self._on_done)
-
- def _submit(self):
- user = self.ed_user.text().strip()
- password = self.ed_pass.text()
- if not user or not password:
- self.lb_err.setText("请输入用户名和密码")
- self.lb_err.show()
- return
- self.btn_ok.setEnabled(False)
- self.btn_cancel.setEnabled(False)
- self.btn_ok.setText("登录中") # 登录按钮变灰并显示"登录中"
- threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
-
- def _worker(self, user, password):
- """后台登录:SSO 认证拉取 nodes.json,校验后加密写入用户数据目录。"""
+def _logout_user_data():
+ """清除本地登录凭据与节点文件,并让内存节点缓存失效。"""
+ global _NODES
+ for name in ("auth.json", "nodes.json"):
try:
- import json
- url = str(_config_val("nodes_url", "") or "").strip()
- if not url:
- raise RuntimeError("config.json 未配置 nodes_url")
- data = _fetch_nodes_auth(url, user, password)
- if not (isinstance(data, list) and data
- and all(isinstance(n, dict) for n in data)
- and str(data[0].get("domain") or "").strip()):
- raise ValueError("返回的节点数据无效")
- text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
- _USER_DIR.mkdir(parents=True, exist_ok=True)
- (_USER_DIR / "nodes.json").write_bytes(_nodes_encrypt(text))
- _auth_save(user, password) # 记住 SSO 凭据,之后每次启动自动同步节点
- self._done.emit(True, str(len(data)))
- except _AuthError as e:
- # 服务端拒绝:直接展示其中文原因(认证失败/未开通/限流等)
- self._done.emit(False, str(e))
- except Exception as e:
- self._done.emit(False, f"登录失败: {e}")
-
- def _on_done(self, ok, info):
- self.btn_ok.setEnabled(True)
- self.btn_cancel.setEnabled(True)
- self.btn_ok.setText("登录")
- if ok:
- log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)")
- self.accept()
- else:
- self.lb_err.setText(info)
- self.lb_err.show()
-
-# ── 代理白名单弹窗 ─────────────────────────────────────
-
-class WhitelistDialog(QDialog):
- """代理白名单编辑弹窗:可编辑列表(Windows 环境变量风格),
- 每条规则带复选框启用/禁用,支持添加/删除,保存后立即生效。"""
-
- def __init__(self, parent=None):
- super().__init__(parent)
- self.setWindowTitle("代理白名单")
- self.setWindowIcon(load_icon())
- self.setFixedSize(380, 400)
- 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: 6px 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: 3px 6px; 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: 4px 12px;
- font-size: 12px; font-weight: bold;
- }}
- QPushButton#btnOk:hover {{ background-color: #5ed978; }}
- QPushButton#btnCancel {{
- background-color: {C_CARD}; color: {C_SUBTEXT};
- border: 1px solid {C_BORDER}; border-radius: 6px;
- padding: 4px 12px; font-size: 12px;
- }}
- QPushButton#btnSmall {{
- background-color: {C_CARD}; color: {C_SUBTEXT};
- border: 1px solid {C_BORDER}; border-radius: 6px;
- padding: 3px 10px; font-size: 11px;
- }}
- QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
- """)
-
- layout = QVBoxLayout(self)
- layout.setSpacing(10)
- 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("命中以下规则的连接将强制直连(不经过代理)")
- hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
- hint.setAlignment(Qt.AlignCenter)
- layout.addWidget(hint)
-
- self.lb_err = QLabel("")
- self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
- self.lb_err.setFixedHeight(14)
- self.lb_err.hide() # 无错误时不占空间
- layout.addWidget(self.lb_err)
-
- # 规则列表:每条前面带复选框(勾选启用 / 取消禁用)
- self.list_widget = QListWidget()
- self.list_widget.setToolTip("勾选启用规则;可多选后删除")
- for r in _whitelist_rules():
- self._add_item(r["value"], r.get("enabled", True))
- layout.addWidget(self.list_widget, 1)
-
- # 添加行:输入框 + 添加按钮
- add_row = QHBoxLayout()
- add_row.setSpacing(8)
- self.ed_add = QLineEdit()
- self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com")
- self.ed_add.returnPressed.connect(self._add_rule)
- add_row.addWidget(self.ed_add, 1)
- self.btn_add = QPushButton("添加")
- self.btn_add.setObjectName("btnOk")
- self.btn_add.setStyleSheet(f"""
- QPushButton {{
- background-color: {C_GREEN}; color: #fff; border: none;
- border-radius: 6px; padding: 6px 14px;
- font-size: 12px; font-weight: bold;
- }}
- QPushButton:hover {{ background-color: #5ed978; }}
- """)
- self.btn_add.clicked.connect(self._add_rule)
- add_row.addWidget(self.btn_add)
- layout.addLayout(add_row)
-
- # 删除选中 + 底部按钮
- mid_row = QHBoxLayout()
- self.btn_delete = QPushButton("删除选中")
- self.btn_delete.setObjectName("btnSmall")
- self.btn_delete.clicked.connect(self._delete_selected)
- mid_row.addWidget(self.btn_delete)
- mid_row.addStretch()
- layout.addLayout(mid_row)
-
- btns = QHBoxLayout()
- btns.setSpacing(10)
- btns.addStretch()
- self.btn_cancel = QPushButton("取消")
- self.btn_cancel.setObjectName("btnCancel")
- self.btn_cancel.clicked.connect(self.reject)
- btns.addWidget(self.btn_cancel)
- self.btn_ok = QPushButton("保存")
- self.btn_ok.setObjectName("btnOk")
- self.btn_ok.clicked.connect(self._save)
- btns.addWidget(self.btn_ok)
- layout.addLayout(btns)
-
- def _add_item(self, value, enabled):
- item = QListWidgetItem(value)
- item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
- item.setCheckState(Qt.Checked if enabled else Qt.Unchecked)
- self.list_widget.addItem(item)
-
- def _add_rule(self):
- value, err = _whitelist_validate_entry(self.ed_add.text())
- if err:
- self.lb_err.setText(err)
- self.lb_err.show()
- return
- # 去重(不区分大小写)
- for i in range(self.list_widget.count()):
- if self.list_widget.item(i).text().lower() == value.lower():
- self.lb_err.setText("该规则已存在")
- self.lb_err.show()
- return
- self.lb_err.hide()
- self._add_item(value, True)
- self.ed_add.clear()
-
- def _delete_selected(self):
- for item in self.list_widget.selectedItems():
- self.list_widget.takeItem(self.list_widget.row(item))
-
- def _save(self):
- """收集列表内容保存(未点保存前所有改动均可取消)。"""
- rules = []
- for i in range(self.list_widget.count()):
- item = self.list_widget.item(i)
- value = str(item.text()).strip()
- if value:
- rules.append({"value": value, "enabled": item.checkState() == Qt.Checked})
- _whitelist_save(rules)
- self.accept()
-
-# ── 设置弹窗 ─────────────────────────────────────────
-
-class SettingsDialog(QDialog):
- """设置弹窗:代理转发端口 + 退出登录。"""
-
- def __init__(self, parent=None, port_locked=False):
- super().__init__(parent)
- self.setWindowTitle("设置")
- self.setWindowIcon(load_icon())
- self.setFixedSize(300, 354)
- 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: 2px 8px;
- color: {C_TEXT};
- font-size: 12px;
- }}
- """)
-
- layout = QVBoxLayout(self)
- layout.setSpacing(12)
- 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)
-
- # 版本号 + 手动「检查更新」入口(整体居中,中间隔小空)
- ver_row = QHBoxLayout()
- ver_row.addStretch()
- ver_lab = QLabel(f"版本号: {VERSION}")
- ver_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
- ver_row.addWidget(ver_lab)
- ver_row.addSpacing(4)
- self.link_check_update = NodeLabel(
- "检查更新", tooltip="手动检查更新",
- base=f"color: {C_SUBTEXT}; font-size: 11px;",
- hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;")
- self.link_check_update.clicked.connect(self._manual_check_update)
- ver_row.addWidget(self.link_check_update)
- ver_row.addStretch()
- layout.addLayout(ver_row)
-
- # 提示语:标题正下方(保存结果/错误也显示在这行)
- self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效")
- self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
- self.lb_hint.setFixedHeight(14)
- self.lb_hint.setAlignment(Qt.AlignCenter)
- layout.addWidget(self.lb_hint)
-
- layout.addStretch() # 设置项在空白部分纵向居中(上下均分)
-
- # 代理白名单:第一个设置项,点「配置」打开编辑弹窗
- wl_row = QHBoxLayout()
- wl_lab = QLabel("代理白名单")
- wl_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
- wl_lab.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
- wl_row.addWidget(wl_lab)
- wl_row.addStretch()
- self.btn_whitelist = QPushButton("配置")
- self.btn_whitelist.setCursor(Qt.PointingHandCursor)
- self.btn_whitelist.setStyleSheet(f"""
- QPushButton {{
- background-color: {C_CARD}; color: {C_SUBTEXT};
- border: 1px solid {C_BORDER}; border-radius: 6px;
- padding: 2px 10px; font-size: 11px;
- }}
- QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
- """)
- self.btn_whitelist.clicked.connect(self._open_whitelist)
- wl_row.addWidget(self.btn_whitelist)
- layout.addLayout(wl_row)
-
- # 代理转发端口
- port_row = QHBoxLayout()
- port_lab = QLabel("代理转发端口")
- port_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
- port_row.addWidget(port_lab)
- port_row.addStretch()
- self.ed_port = QLineEdit(str(_TLS_LOCAL_PORT))
- self.ed_port.setFixedSize(54, 22) # 与右侧开关同高,和其他设置项同一水平
- self.ed_port.setEnabled(not port_locked)
- port_row.addWidget(self.ed_port)
- layout.addLayout(port_row)
-
- # 更新方式:左文字 + 右苹果式开关(默认关 = 直连仓库)
- upd_row = QHBoxLayout()
- upd_lab = QLabel("使用代理更新")
- upd_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
- upd_row.addWidget(upd_lab)
- upd_row.addStretch()
- self.cb_update_proxy = ToggleSwitch(checked=_update_via_proxy())
- self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
- upd_row.addWidget(self.cb_update_proxy)
- layout.addLayout(upd_row)
-
- # 跳过检查更新:左文字 + 右开关(默认关;不影响节点更新与认证)
- skip_row = QHBoxLayout()
- skip_lab = QLabel("跳过检查更新")
- skip_lab.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
- skip_row.addWidget(skip_lab)
- skip_row.addStretch()
- self.cb_skip_update = ToggleSwitch(checked=_skip_update_check())
- self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
- skip_row.addWidget(self.cb_skip_update)
- layout.addLayout(skip_row)
-
- layout.addStretch()
-
- # 底部:保存更改 + 退出登录 并排
- btns = QHBoxLayout()
- btns.setSpacing(10)
- btns.addStretch()
- self.btn_save = QPushButton("保存更改")
- self.btn_save.setStyleSheet(f"""
- QPushButton {{
- background-color: {C_GREEN}; color: #fff; border: none;
- border-radius: 6px; padding: 5px 14px;
- font-size: 12px; font-weight: bold;
- }}
- QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }}
- """)
- self.btn_save.setEnabled(not port_locked)
- self.btn_save.clicked.connect(self._save_settings)
- btns.addWidget(self.btn_save)
- self.btn_logout = QPushButton("退出登录")
- 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.clicked.connect(self._logout)
- btns.addWidget(self.btn_logout)
- btns.addStretch()
- layout.addLayout(btns)
-
- def _manual_check_update(self):
- """手动触发一次检查更新(后台线程,复用主窗口逻辑)。"""
- parent = self.parent()
- if not isinstance(parent, MainWindow):
- return
- self.lb_hint.setText("正在检查更新...")
- self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
- try:
- parent._update_done.connect(self._on_check_done)
- except TypeError:
- pass # 已连接过,忽略
- threading.Thread(target=parent._check_update, kwargs={"manual": True}, daemon=True).start()
-
- def _on_check_done(self):
- self.lb_hint.setText("检查完成,结果见主界面日志")
- self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
- try:
- self.parent()._update_done.disconnect(self._on_check_done)
+ (_USER_DIR / name).unlink(missing_ok=True)
except Exception:
pass
+ _NODES = None
+ log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录")
- def _open_whitelist(self):
- """打开代理白名单编辑弹窗(保存后立即生效,无需重启代理)。"""
- WhitelistDialog(self).exec_()
- def _save_settings(self):
- """保存更改:统一校验并应用全部设置项(后续新增设置项在此扩展)。"""
- port = _parse_port(self.ed_port.text())
- if port is None:
- self.lb_hint.setText("端口应为 1-65535 的数字")
- self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
- return
- _set_local_port(port)
- _set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
- _set_skip_update_check(bool(self.cb_skip_update.isChecked()))
- self.ed_port.setText(str(port))
- self.lb_hint.setText("已保存,下次开启代理生效")
- self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
+def _settings_services():
+ return SettingsServices(
+ load_icon=load_icon,
+ version=VERSION,
+ get_port=lambda: _TLS_LOCAL_PORT,
+ get_update_via_proxy=_update_via_proxy,
+ get_skip_update_check=_skip_update_check,
+ parse_port=_parse_port,
+ set_port=_set_local_port,
+ 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_(),
+ logout=_logout_user_data,
+ )
- def _logout(self):
- """退出登录:删除加密凭据与本地节点配置,清空内存缓存。"""
- global _NODES
- for name in ("auth.json", "nodes.json"):
- try:
- (_USER_DIR / name).unlink(missing_ok=True)
- except Exception:
- pass
- _NODES = None
- log("已退出登录:凭据与节点配置已清除,下次启动或开启代理时需重新登录")
- self.accept()
-
-# ── 苹果风格开关 ─────────────────────────────────────
-
-class ToggleSwitch(QCheckBox):
- """苹果风格开关:左关右开,绿/灰胶囊 + 滑动圆钮(自绘,无图片资源)。"""
-
- def __init__(self, checked=False, parent=None):
- super().__init__(parent)
- self.setChecked(checked)
- self.setFixedSize(42, 22)
- self.setCursor(Qt.PointingHandCursor)
-
- def paintEvent(self, e):
- p = QPainter(self)
- p.setRenderHint(QPainter.Antialiasing)
- p.setPen(Qt.NoPen)
- # 胶囊背景:开=主题绿,关=浅灰
- p.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4"))
- p.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11)
- # 白色圆钮:开在右,关在左
- knob_x = 20.0 if self.isChecked() else 2.0
- p.setBrush(QColor("#ffffff"))
- p.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0))
- p.end()
-
- def mousePressEvent(self, e):
- # 显式处理点击切换:自绘控件在部分样式引擎下默认点击不触发
- if e.button() == Qt.LeftButton:
- self.setChecked(not self.isChecked())
- self.repaint()
- e.accept()
- return
- super().mousePressEvent(e)
-
-# ── 状态指示灯(小圆点) ───────────────────────────────
-
-class StatusDot(QLabel):
- def __init__(self):
- super().__init__()
- self.setFixedSize(14, 14)
- self.set_off()
-
- def set_on(self):
- self.setStyleSheet(f"""
- QLabel {{
- background-color: {C_GREEN};
- border-radius: 7px;
- border: 1px solid rgba(81, 207, 102, 50);
- }}
- """)
-
- def set_off(self):
- self.setStyleSheet(f"""
- QLabel {{
- background-color: {C_RED};
- border-radius: 7px;
- border: 1px solid rgba(255, 107, 107, 50);
- }}
- """)
-
- def set_disabled(self):
- self.setStyleSheet(f"""
- QLabel {{
- background-color: {C_BORDER};
- border-radius: 7px;
- border: 1px solid rgba(136, 153, 170, 50);
- }}
- """)
+# ── 代理白名单弹窗 ─────────────────────────────────────
# ── 主窗口 ────────────────────────────────────────────
@@ -3032,7 +2080,7 @@ class MainWindow(QWidget):
# ── 设置 ──
def _show_settings_dialog(self):
"""打开设置弹窗(代理运行中锁定端口修改);退出登录后立即触发登录流程。"""
- SettingsDialog(self, port_locked=self._proxy_set_by_us).exec_()
+ SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_()
self._refresh_subtitle()
if _endpoint_domain() == "???":
self._maybe_show_login() # 退出登录后重新走登录(隐藏主窗口/取消退出)
@@ -3050,7 +2098,7 @@ class MainWindow(QWidget):
def _show_login_dialog(self):
"""弹出登录框;成功后重载节点并刷新标题。"""
- dlg = LoginDialog(self)
+ dlg = LoginDialog(_login_services(), self)
if dlg.exec_() == QDialog.Accepted:
_reload_nodes()
self._refresh_subtitle()
diff --git a/lite/others/nodes_tool/nodes_tool.py b/lite/others/nodes_tool/nodes_tool.py
new file mode 100644
index 0000000..47ac9ce
--- /dev/null
+++ b/lite/others/nodes_tool/nodes_tool.py
@@ -0,0 +1,126 @@
+# -*- coding: utf-8 -*-
+"""管理用户数据目录里的 nodes.json(Windows DPAPI 加密存储)。
+
+用法(在 Windows 上运行):
+ python nodes_tool.py encrypt <明文nodes.json> # 加密导入到 %LOCALAPPDATA%\\RRPP
+ python nodes_tool.py decrypt # 打印解密后的内容
+ python nodes_tool.py path # 显示存储路径
+
+注意:DPAPI 加密后只有「本机 + 本用户」能解密;文件拷到别的机器/账户无效。
+"""
+import ctypes
+import json
+import os
+import sys
+from pathlib import Path
+
+
+def user_data_dir():
+ if os.name == "nt" and os.environ.get("LOCALAPPDATA"):
+ return Path(os.environ["LOCALAPPDATA"]) / "RRPP"
+ return Path.home() / "RRPP"
+
+
+class _DATA_BLOB(ctypes.Structure):
+ _fields_ = [("cbData", ctypes.c_uint32),
+ ("pbData", ctypes.POINTER(ctypes.c_char))]
+
+
+def dpapi_protect(data: bytes) -> bytes:
+ crypt32 = ctypes.WinDLL("crypt32")
+ kernel32 = ctypes.WinDLL("kernel32")
+ crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.c_wchar_p,
+ ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
+ ctypes.c_void_p, ctypes.c_uint32,
+ ctypes.POINTER(_DATA_BLOB)]
+ crypt32.CryptProtectData.restype = ctypes.c_bool
+ kernel32.LocalFree.argtypes = [ctypes.c_void_p]
+
+ buf = ctypes.create_string_buffer(data, len(data))
+ blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
+ blob_out = _DATA_BLOB()
+ if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
+ None, None, 0, ctypes.byref(blob_out)):
+ raise OSError("CryptProtectData 调用失败")
+ try:
+ return ctypes.string_at(blob_out.pbData, blob_out.cbData)
+ finally:
+ kernel32.LocalFree(blob_out.pbData)
+
+
+def dpapi_unprotect(data: bytes) -> bytes:
+ crypt32 = ctypes.WinDLL("crypt32")
+ kernel32 = ctypes.WinDLL("kernel32")
+ crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.POINTER(ctypes.c_wchar_p),
+ ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p,
+ ctypes.c_void_p, ctypes.c_uint32,
+ ctypes.POINTER(_DATA_BLOB)]
+ crypt32.CryptUnprotectData.restype = ctypes.c_bool
+ kernel32.LocalFree.argtypes = [ctypes.c_void_p]
+
+ buf = ctypes.create_string_buffer(data, len(data))
+ blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
+ descr = ctypes.c_wchar_p()
+ blob_out = _DATA_BLOB()
+ if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None,
+ None, None, 0, ctypes.byref(blob_out)):
+ raise OSError("CryptUnprotectData 调用失败")
+ try:
+ return ctypes.string_at(blob_out.pbData, blob_out.cbData)
+ finally:
+ kernel32.LocalFree(blob_out.pbData)
+ if descr.value:
+ kernel32.LocalFree(descr)
+
+
+def cmd_encrypt(src: str):
+ plain = Path(src)
+ if not plain.is_file():
+ print(f"未找到 {plain}")
+ sys.exit(1)
+ data = json.loads(plain.read_text("utf-8"))
+ if not (isinstance(data, list) and data and all(isinstance(n, dict) for n in data)):
+ print("nodes.json 应为非空节点数组")
+ sys.exit(1)
+ out_dir = user_data_dir()
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out = out_dir / "nodes.json"
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
+ blob = dpapi_protect(text.encode("utf-8")) if os.name == "nt" else text.encode("utf-8")
+ out.write_bytes(blob)
+ print(f"已{'加密' if os.name == 'nt' else '写入(非Windows不加密)'}: {out}({len(data)} 个节点)")
+
+
+def cmd_decrypt():
+ f = user_data_dir() / "nodes.json"
+ if not f.is_file():
+ print(f"未找到 {f}")
+ sys.exit(1)
+ blob = f.read_bytes()
+ if os.name == "nt":
+ try:
+ print(dpapi_unprotect(blob).decode("utf-8"), end="")
+ return
+ except Exception as e:
+ print(f"DPAPI 解密失败: {e}")
+ print(blob.decode("utf-8"), end="")
+
+
+def main():
+ if len(sys.argv) < 2:
+ print(__doc__)
+ sys.exit(1)
+ cmd = sys.argv[1]
+ if cmd == "encrypt" and len(sys.argv) == 3:
+ cmd_encrypt(sys.argv[2])
+ elif cmd == "decrypt":
+ cmd_decrypt()
+ elif cmd == "path":
+ print(user_data_dir())
+ else:
+ print(__doc__)
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lite/others/update_pac_domains/update_pac_domains.py b/lite/others/update_pac_domains/update_pac_domains.py
new file mode 100644
index 0000000..4310e32
--- /dev/null
+++ b/lite/others/update_pac_domains/update_pac_domains.py
@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""更新 pac_domains.json:从 v2fly/domain-list-community (geosite) 拉取境外服务
+分类,与现有手工条目合并,生成新的强制走隧道域名后缀表。
+
+用法:
+ python update_pac_domains.py # 拉取并更新(先自动备份)
+ python update_pac_domains.py --dry-run # 只统计,不写文件
+
+geosite 数据格式(data/<分类>,每行一条规则):
+ include:xxx 引用其他分类(递归解析)
+ example.com 匹配域名及其子域(与我们的后缀匹配一致)
+ +example.com 同上(additive)
+ .example.com 仅精确匹配(不匹配子域)-> 跳过
+ -example.com 排除 -> 跳过
+ /regex/ 正则 -> 跳过
+ example.com @cn 属性标签 -> 去掉标签
+ 无点的单标签(如 chrome)-> 无实际主机名会命中,跳过
+"""
+import argparse
+import json
+import sys
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+BASE_URL = "https://raw.githubusercontent.com/v2fly/domain-list-community/master/data/"
+# 需要强制走隧道的境外服务分类(geosite 分类名),可按需增删
+CATEGORIES = ["google", "apple", "telegram", "openai", "twitter", "facebook",
+ "github", "netflix", "discord", "spotify", "whatsapp", "paypal"]
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+LITE_DIR = SCRIPT_DIR.parent.parent
+PAC_FILE = LITE_DIR / "pac_domains.json"
+BACKUP_DIR = LITE_DIR / "backups"
+# 本地 pac_domains.json 已不再随项目保留:缺失时从 CDN 拉当前版本作合并基底
+CDN_URL = "https://cdn.rrrosmontis.icu/RRPP/PAC/pac_domains.json"
+
+
+def fetch(cat, tries=2):
+ for i in range(tries):
+ try:
+ with urllib.request.urlopen(BASE_URL + cat, timeout=20) as r:
+ return r.read().decode("utf-8", "replace").splitlines()
+ except Exception:
+ if i == tries - 1:
+ raise
+
+
+def resolve(cat, seen=None):
+ """递归展开 include:,返回原始行列表;循环引用用 seen 防死循环。"""
+ seen = seen or set()
+ if cat in seen:
+ return []
+ seen.add(cat)
+ lines = []
+ try:
+ for raw in fetch(cat):
+ line = raw.strip()
+ if line.startswith("include:"):
+ lines += resolve(line[len("include:"):].strip(), seen)
+ else:
+ lines.append(line)
+ except Exception as e:
+ print(f" [warn] 拉取 {cat} 失败: {e}")
+ return lines
+
+
+def parse_domains(lines):
+ """过滤 geosite 行,只保留可映射为后缀匹配的纯域名。"""
+ out = set()
+ for line in lines:
+ s = line.strip()
+ if not s or s.startswith("#") or s.startswith("/"):
+ continue
+ if s.startswith("-") or s.startswith("."):
+ continue
+ s = s.lstrip("+").split("@")[0].strip()
+ if "." not in s:
+ continue
+ out.add(s.lower())
+ return out
+
+
+def main():
+ parser = argparse.ArgumentParser(description="更新 pac_domains.json")
+ parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件")
+ args = parser.parse_args()
+
+ if PAC_FILE.is_file():
+ cur = set(json.loads(PAC_FILE.read_text("utf-8"))["domains"])
+ else:
+ # 项目目录已不保留该文件:从 CDN 拉当前版本作合并基底(保留其中的手工条目)
+ print(f"本地未找到 {PAC_FILE},从 CDN 拉取当前版本作为合并基底")
+ try:
+ with urllib.request.urlopen(CDN_URL, timeout=20) as r:
+ cur = set(json.loads(r.read().decode("utf-8"))["domains"])
+ except Exception as e:
+ print(f"拉取失败: {e}")
+ sys.exit(1)
+
+ print(f"拉取 geosite 分类: {', '.join(CATEGORIES)}")
+ merged = set()
+ for cat in CATEGORIES:
+ doms = parse_domains(resolve(cat))
+ merged |= doms
+ print(f" {cat:<12} -> {len(doms):>5} 条")
+
+ new = merged - cur
+ kept = cur & merged
+ only_cur = cur - merged
+ print(f"\n合并后总数: {len(merged | cur)}(geosite {len(merged)} + 现有 {len(cur)})")
+ print(f"新增 {len(new)},与现有重叠 {len(kept)},现有手工条目 {len(only_cur)} 条全部保留")
+
+ if args.dry_run:
+ print("--dry-run: 未写文件")
+ return
+
+ final = sorted(merged | cur)
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
+ comment = (
+ f"强制走代理隧道的域名后缀(IP 智能分流的补充规则):命中这些域名的连接即使"
+ f"解析到国内 IP 也强制走隧道。匹配规则:host == 条目 或 host 以 \".条目\" 结尾。"
+ f"由 others/update_pac_domains/update_pac_domains.py 从 v2fly geosite "
+ f"({', '.join(CATEGORIES)}) 生成于 {now},手工条目已保留;"
+ f"客户端按文件内 version/date 从 CDN 在线更新到 %LOCALAPPDATA%\\RRPP。"
+ )
+ out = {"_comment": comment,
+ "version": datetime.now().strftime("%Y%m%d%H%M"),
+ "date": datetime.now().strftime("%Y-%m-%d"),
+ "domains": final}
+
+ BACKUP_DIR.mkdir(exist_ok=True)
+ bak = BACKUP_DIR / f"pac_domains.json.bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
+ bak.write_bytes(PAC_FILE.read_bytes())
+ print(f"已备份: {bak}")
+
+ PAC_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", "utf-8")
+ size = PAC_FILE.stat().st_size / 1024
+ print(f"已写入: {PAC_FILE}({len(final)} 条,{size:.0f} KB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lite/others/update_pac_ip_cn/update_pac_ip_cn.py b/lite/others/update_pac_ip_cn/update_pac_ip_cn.py
new file mode 100644
index 0000000..d5f8a5a
--- /dev/null
+++ b/lite/others/update_pac_ip_cn/update_pac_ip_cn.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+"""更新 pac_ip_cn.json:从 17mon/china_ip_list 拉取中国 IPv4 段,写入带
+version/date 戳的本地 PAC 数据文件(客户端按 pac_manifest.json 比对在线更新)。
+
+用法:
+ python update_pac_ip_cn.py # 拉取并更新(先自动备份)
+ python update_pac_ip_cn.py --dry-run # 只统计,不写文件
+"""
+import argparse
+import ipaddress
+import json
+import sys
+import urllib.request
+from datetime import datetime
+from pathlib import Path
+
+SRC_URL = "https://raw.githubusercontent.com/17mon/china_ip_list/master/china_ip_list.txt"
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+LITE_DIR = SCRIPT_DIR.parent.parent
+PAC_FILE = LITE_DIR / "pac_ip_cn.json"
+BACKUP_DIR = LITE_DIR / "backups"
+
+
+def fetch_lines(tries=2):
+ for i in range(tries):
+ try:
+ with urllib.request.urlopen(SRC_URL, timeout=20) as r:
+ return r.read().decode("utf-8", "replace").splitlines()
+ except Exception:
+ if i == tries - 1:
+ raise
+
+
+def main():
+ parser = argparse.ArgumentParser(description="更新 pac_ip_cn.json")
+ parser.add_argument("--dry-run", action="store_true", help="只统计,不写文件")
+ args = parser.parse_args()
+
+ print(f"拉取: {SRC_URL}")
+ cidrs, skipped = [], 0
+ for line in fetch_lines():
+ s = line.strip()
+ if not s or s.startswith("#"):
+ continue
+ try:
+ net = ipaddress.ip_network(s, strict=False)
+ if net.version == 4:
+ cidrs.append(str(net))
+ else:
+ skipped += 1
+ except ValueError:
+ skipped += 1
+ cidrs.sort(key=lambda c: (int(ipaddress.ip_network(c, strict=False).network_address),
+ ipaddress.ip_network(c, strict=False).prefixlen))
+ print(f"共 {len(cidrs)} 条 IPv4 CIDR,跳过 {skipped} 条")
+
+ if args.dry_run:
+ print("--dry-run: 未写文件")
+ return
+
+ now = datetime.now()
+ comment = (
+ f"中国 IPv4 地址段(CIDR)。智能分流依据:解析 IP 命中列表 → 直连,"
+ f"未命中 → 走加密隧道。由 others/update_pac_ip_cn/update_pac_ip_cn.py "
+ f"从 17mon/china_ip_list 生成于 {now.strftime('%Y-%m-%d %H:%M')};"
+ f"客户端按文件内 version/date 从 CDN 在线更新到 %LOCALAPPDATA%\\RRPP。"
+ )
+ out = {"_comment": comment,
+ "version": now.strftime("%Y%m%d%H%M"),
+ "date": now.strftime("%Y-%m-%d"),
+ "cidrs": cidrs}
+
+ BACKUP_DIR.mkdir(exist_ok=True)
+ bak = BACKUP_DIR / f"pac_ip_cn.json.bak_{now.strftime('%Y%m%d_%H%M%S')}"
+ if PAC_FILE.is_file():
+ bak.write_bytes(PAC_FILE.read_bytes())
+ print(f"已备份: {bak}")
+
+ PAC_FILE.write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", "utf-8")
+ size = PAC_FILE.stat().st_size / 1024
+ print(f"已写入: {PAC_FILE}({len(cidrs)} 条,{size:.0f} KB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lite/rrpp_core/__init__.py b/lite/rrpp_core/__init__.py
new file mode 100644
index 0000000..0a0a01f
--- /dev/null
+++ b/lite/rrpp_core/__init__.py
@@ -0,0 +1 @@
+"""RRPP Lite 的无 GUI 核心逻辑。"""
diff --git a/lite/rrpp_core/forwarder_primitives.py b/lite/rrpp_core/forwarder_primitives.py
new file mode 100644
index 0000000..5b2c864
--- /dev/null
+++ b/lite/rrpp_core/forwarder_primitives.py
@@ -0,0 +1,91 @@
+"""本地转发器的无状态报文解析与改写函数。"""
+
+from urllib.parse import urlsplit
+
+
+HTTP_METHODS = (
+ b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
+ b"HEAD ", b"OPTIONS ", b"PATCH ",
+)
+
+
+def starts_with_http_method(data: bytes) -> bool:
+ return any(data.startswith(method) for method in HTTP_METHODS)
+
+
+def inject_proxy_auth(data: bytes, basic_auth: str) -> bytes:
+ """为完整 HTTP 代理请求注入上游 Basic Authorization。"""
+ if not starts_with_http_method(data):
+ return data
+ end = data.find(b"\r\n\r\n")
+ if end == -1:
+ return data
+ head = data[:end]
+ first_end = head.find(b"\r\n")
+ request_line = head if first_end == -1 else head[:first_end]
+ headers = b"" if first_end == -1 else head[first_end + 2:]
+ for line in headers.split(b"\r\n"):
+ if b":" not in line:
+ continue
+ name = line[:line.index(b":")].strip().lower()
+ if name == b"authorization":
+ return data
+ auth_line = f"Authorization: Basic {basic_auth}\r\n".encode()
+ return request_line + b"\r\n" + auth_line + headers + data[end:]
+
+
+def parse_connect_authority(first_line: str):
+ """从 CONNECT 首行提取 ``(host, port)``,失败时返回 ``None``。"""
+ parts = first_line.split(" ")
+ if len(parts) < 2:
+ return None
+ authority = parts[1]
+ if authority.startswith("[") and "]" in authority:
+ try:
+ host = authority[1:authority.index("]")]
+ tail = authority[authority.index("]") + 1:]
+ if tail.startswith(":") and tail[1:].isdigit():
+ return host, int(tail[1:])
+ except ValueError:
+ pass
+ if ":" not in authority:
+ return None
+ host, _, port_s = authority.rpartition(":")
+ if not host or not port_s.isdigit():
+ return None
+ return host, int(port_s)
+
+
+def parse_http_absolute(first_line: str):
+ """从明文 HTTP 代理首行提取 ``(host, port)``,失败时返回 ``None``。"""
+ parts = first_line.split(" ")
+ if len(parts) < 2 or not parts[1].lower().startswith(("http://", "https://")):
+ return None
+ parsed = urlsplit(parts[1])
+ if not parsed.hostname:
+ return None
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
+ return parsed.hostname, port
+
+
+def force_http_close(buffer: bytes) -> bytes:
+ """直连明文 HTTP 时强制 Connection: close,避免错复用连接。"""
+ end = buffer.find(b"\r\n\r\n")
+ if end == -1:
+ return buffer
+ head, tail = buffer[:end], buffer[end:]
+ lines = head.split(b"\r\n")
+ out = [lines[0]]
+ conn_seen = False
+ for line in lines[1:]:
+ low = line.lower()
+ if low.startswith(b"connection:"):
+ out.append(b"Connection: close")
+ conn_seen = True
+ elif low.startswith(b"proxy-connection:"):
+ continue
+ else:
+ out.append(line)
+ if not conn_seen:
+ out.append(b"Connection: close")
+ return b"\r\n".join(out) + tail
diff --git a/lite/rrpp_core/network_primitives.py b/lite/rrpp_core/network_primitives.py
new file mode 100644
index 0000000..29c0eff
--- /dev/null
+++ b/lite/rrpp_core/network_primitives.py
@@ -0,0 +1,82 @@
+"""路由、白名单和转发器共用的网络基础函数。"""
+
+import functools
+import ipaddress
+import socket
+import threading
+import time
+
+
+def ttl_cache(seconds: int, maxsize: int = 16384):
+ """带 TTL 的小型线程安全缓存,并保留 ``cache_clear`` 兼容接口。"""
+ def decorator(function):
+ store = {}
+ lock = threading.Lock()
+
+ @functools.wraps(function)
+ def wrapper(*args, **kwargs):
+ key = (args, tuple(sorted(kwargs.items())))
+ now = time.time()
+ with lock:
+ hit = store.get(key)
+ if hit and now - hit[0] < seconds:
+ return hit[1]
+ value = function(*args, **kwargs)
+ with lock:
+ store[key] = (now, value)
+ if len(store) > maxsize:
+ cutoff = now - seconds
+ for cached_key, (timestamp, _) in list(store.items()):
+ if timestamp < cutoff:
+ del store[cached_key]
+ if len(store) > maxsize:
+ store.clear()
+ return value
+
+ def cache_clear():
+ with lock:
+ store.clear()
+
+ wrapper.cache_clear = cache_clear
+ return wrapper
+ return decorator
+
+
+def is_private_ipv4_int(value: int) -> bool:
+ """整数形式 IPv4 是否属于常见私网、回环或链路本地段。"""
+ return (
+ (value & 0xFF000000) == 0x0A000000
+ or (value & 0xFFF00000) == 0xAC100000
+ or (value & 0xFFFF0000) == 0xC0A80000
+ or (value & 0xFF000000) == 0x7F000000
+ or (value & 0xFFFF0000) == 0xA9FE0000
+ )
+
+
+def resolve_host_ips(host: str):
+ """解析主机名的 IPv4/IPv6 集合;字面量 IP 返回自身,失败返回空集合。"""
+ try:
+ return {ipaddress.ip_address(host)}
+ except ValueError:
+ pass
+ try:
+ return {
+ ipaddress.ip_address(address[4][0])
+ for address in socket.getaddrinfo(host, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
+ }
+ except Exception:
+ return set()
+
+
+def is_private_host(host: str) -> bool:
+ """目标是否命中私网、回环或 IPv6 ULA/链路本地地址。"""
+ for address in resolve_host_ips(host):
+ if address.version == 4 and is_private_ipv4_int(int(address)):
+ return True
+ if address.version == 6 and (
+ address.is_loopback
+ or address.is_link_local
+ or address in ipaddress.ip_network("fc00::/7")
+ ):
+ return True
+ return False
diff --git a/lite/rrpp_core/node_api.py b/lite/rrpp_core/node_api.py
new file mode 100644
index 0000000..30fd724
--- /dev/null
+++ b/lite/rrpp_core/node_api.py
@@ -0,0 +1,36 @@
+"""节点订阅的认证请求协议。"""
+
+import base64
+import json
+import urllib.error
+import urllib.request
+
+
+class NodeAuthError(Exception):
+ """订阅服务拒绝请求时携带的 HTTP 状态和用户可读原因。"""
+
+ def __init__(self, message, code=0):
+ super().__init__(message)
+ self.code = code
+
+
+def b64url(value: str) -> str:
+ """SSO 查询参数使用的无 padding URL-safe Base64。"""
+ return base64.urlsafe_b64encode(str(value).encode("utf-8")).decode("ascii").rstrip("=")
+
+
+def fetch_nodes_auth(url, user, password, timeout=10):
+ """直连拉取需 SSO 查询参数认证的 nodes.json。"""
+ opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
+ separator = "&" if "?" in url else "?"
+ request_url = f"{url}{separator}user={b64url(user)}&password={b64url(password)}"
+ try:
+ with opener.open(request_url, timeout=timeout) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except urllib.error.HTTPError as error:
+ body = ""
+ try:
+ body = error.read().decode("utf-8", "replace").strip()
+ except Exception:
+ pass
+ raise NodeAuthError(body or f"请求被拒绝 (HTTP {error.code})", error.code) from None
diff --git a/lite/rrpp_core/node_secrets.py b/lite/rrpp_core/node_secrets.py
new file mode 100644
index 0000000..fca6a15
--- /dev/null
+++ b/lite/rrpp_core/node_secrets.py
@@ -0,0 +1,95 @@
+"""节点与登录凭据的本机加密存储。"""
+
+import ctypes
+import json
+import os
+
+
+class _DpapiBlob(ctypes.Structure):
+ _fields_ = [
+ ("cbData", ctypes.c_uint32),
+ ("pbData", ctypes.POINTER(ctypes.c_char)),
+ ]
+
+
+class NodeSecretsStore:
+ """使用 Windows DPAPI 保存节点和 SSO 凭据;非 Windows 保持明文兼容。"""
+
+ def __init__(self, user_dir_getter):
+ self._user_dir_getter = user_dir_getter
+
+ @staticmethod
+ def protect_bytes(data: bytes) -> bytes:
+ crypt32 = ctypes.WinDLL("crypt32")
+ kernel32 = ctypes.WinDLL("kernel32")
+ crypt32.CryptProtectData.argtypes = [
+ ctypes.POINTER(_DpapiBlob), ctypes.c_wchar_p, ctypes.POINTER(_DpapiBlob),
+ ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
+ ]
+ crypt32.CryptProtectData.restype = ctypes.c_bool
+ kernel32.LocalFree.argtypes = [ctypes.c_void_p]
+ buffer = ctypes.create_string_buffer(data, len(data))
+ blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
+ blob_out = _DpapiBlob()
+ if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None,
+ None, None, 0, ctypes.byref(blob_out)):
+ raise OSError("CryptProtectData 调用失败")
+ try:
+ return ctypes.string_at(blob_out.pbData, blob_out.cbData)
+ finally:
+ kernel32.LocalFree(blob_out.pbData)
+
+ @staticmethod
+ def unprotect_bytes(data: bytes) -> bytes:
+ crypt32 = ctypes.WinDLL("crypt32")
+ kernel32 = ctypes.WinDLL("kernel32")
+ crypt32.CryptUnprotectData.argtypes = [
+ ctypes.POINTER(_DpapiBlob), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DpapiBlob),
+ ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DpapiBlob),
+ ]
+ crypt32.CryptUnprotectData.restype = ctypes.c_bool
+ kernel32.LocalFree.argtypes = [ctypes.c_void_p]
+ buffer = ctypes.create_string_buffer(data, len(data))
+ blob_in = _DpapiBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
+ description = ctypes.c_wchar_p()
+ blob_out = _DpapiBlob()
+ if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(description), None,
+ None, None, 0, ctypes.byref(blob_out)):
+ raise OSError("CryptUnprotectData 调用失败")
+ try:
+ return ctypes.string_at(blob_out.pbData, blob_out.cbData)
+ finally:
+ kernel32.LocalFree(blob_out.pbData)
+ if description.value:
+ kernel32.LocalFree(description)
+
+ def encrypt_text(self, text: str) -> bytes:
+ if os.name != "nt":
+ return text.encode("utf-8")
+ return self.protect_bytes(text.encode("utf-8"))
+
+ def decrypt_bytes(self, blob: bytes) -> str:
+ if os.name != "nt":
+ return blob.decode("utf-8")
+ try:
+ return self.unprotect_bytes(blob).decode("utf-8")
+ except Exception:
+ return blob.decode("utf-8")
+
+ def save_auth(self, username, password):
+ directory = self._user_dir_getter()
+ directory.mkdir(parents=True, exist_ok=True)
+ text = json.dumps({"username": username, "password": password}, ensure_ascii=False)
+ (directory / "auth.json").write_bytes(self.encrypt_text(text))
+
+ def load_auth(self):
+ try:
+ path = self._user_dir_getter() / "auth.json"
+ if not path.is_file():
+ return None
+ data = json.loads(self.decrypt_bytes(path.read_bytes()))
+ if not (isinstance(data, dict) and data.get("username") and data.get("password")):
+ return None
+ return data
+ except Exception:
+ return None
diff --git a/lite/rrpp_core/pac_data.py b/lite/rrpp_core/pac_data.py
new file mode 100644
index 0000000..5484b8a
--- /dev/null
+++ b/lite/rrpp_core/pac_data.py
@@ -0,0 +1,53 @@
+"""PAC 数据文件的网络传输与安全落盘。"""
+
+import json
+import os
+import urllib.request
+from pathlib import Path
+
+
+def read_version(path):
+ """读取 PAC JSON 的 version/date;无效文件返回空字符串。"""
+ try:
+ data = json.loads(Path(path).read_text("utf-8"))
+ return str(data.get("version") or ""), str(data.get("date") or "")
+ except Exception:
+ return "", ""
+
+
+def no_proxy_opener():
+ """创建忽略环境/系统代理的直连 HTTP opener。"""
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}))
+
+
+def fetch_json(url, timeout=15, tries=2):
+ """以直连方式拉取 JSON;最后一次失败时保留原始异常。"""
+ opener = no_proxy_opener()
+ for attempt in range(tries):
+ try:
+ with opener.open(url, timeout=timeout) as response:
+ return json.loads(response.read().decode("utf-8"))
+ except Exception:
+ if attempt == tries - 1:
+ raise
+
+
+def validate_document(name, data):
+ """校验各 PAC 文件的最小结构,避免错误响应覆盖有效缓存。"""
+ if not isinstance(data, dict):
+ return False
+ if name in ("pac_domains", "pac_direct_domains"):
+ return isinstance(data.get("domains"), list)
+ if name == "pac_ip_cn":
+ return isinstance(data.get("cidrs"), list)
+ return False
+
+
+def atomic_write(directory, name, data):
+ """将 PAC JSON 以 tmp + replace 原子写入指定用户目录。"""
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=True)
+ target = directory / f"{name}.json"
+ temporary_path = directory / f"{name}.json.tmp"
+ temporary_path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
+ os.replace(temporary_path, target)
diff --git a/lite/rrpp_core/routing_primitives.py b/lite/rrpp_core/routing_primitives.py
new file mode 100644
index 0000000..43ed6a8
--- /dev/null
+++ b/lite/rrpp_core/routing_primitives.py
@@ -0,0 +1,81 @@
+"""PAC 路由引擎使用的纯算法,不依赖文件、网络或 Qt。"""
+
+import ipaddress
+import re
+
+
+def merge_ip_ranges(cidrs, version):
+ """将指定 IP 版本的 CIDR 合并为整数闭区间,供二分查找。"""
+ ranges = []
+ for cidr in cidrs:
+ network = ipaddress.ip_network(str(cidr).strip(), strict=False)
+ if network.version == version:
+ ranges.append((int(network.network_address), int(network.broadcast_address)))
+ ranges.sort()
+ merged = []
+ for start, end in ranges:
+ if merged and start <= merged[-1][1] + 1:
+ if end > merged[-1][1]:
+ merged[-1] = (merged[-1][0], end)
+ else:
+ merged.append((start, end))
+ return merged
+
+
+def build_suffix_trie(suffixes):
+ """按反向域名标签建立 Trie,适合大规模后缀规则的 O(标签数) 匹配。"""
+ root = {}
+ for suffix in suffixes:
+ node = root
+ for label in reversed(suffix.split(".")):
+ node = node.setdefault(label, {})
+ node[""] = True
+ return root
+
+
+def suffix_trie_matches(host: str, trie) -> bool:
+ """判断主机名是否命中一个后缀 Trie。"""
+ node = trie
+ for label in reversed(host.rstrip(".").lower().split(".")):
+ node = node.get(label)
+ if node is None:
+ return False
+ if node.get(""):
+ return True
+ return bool(node and node.get(""))
+
+
+def parse_domain_rules(raw_rules, seed_suffixes=()):
+ """解析后缀、``full:`` 与 ``regexp:`` 三种 PAC 域名规则。
+
+ 返回 ``(suffixes, exact, regexes, invalid_regex_count)``。调用方决定
+ 这些规则是“强制代理”还是“明确直连”,从而避免解析层携带业务策略。
+ """
+ suffixes = set(seed_suffixes)
+ exact = set()
+ regexes = []
+ invalid_regexes = 0
+ for raw in raw_rules or ():
+ raw_rule = str(raw).strip()
+ rule = raw_rule.lower()
+ if not raw_rule:
+ continue
+ if rule.startswith("full:"):
+ host = rule[len("full:"):].rstrip(".")
+ if host:
+ exact.add(host)
+ continue
+ if rule.startswith("regexp:"):
+ pattern = raw_rule[len("regexp:"):]
+ if not pattern:
+ invalid_regexes += 1
+ continue
+ try:
+ regexes.append(re.compile(pattern, re.IGNORECASE))
+ except re.error:
+ invalid_regexes += 1
+ continue
+ suffix = rule.lstrip("*.").rstrip(".")
+ if suffix:
+ suffixes.add(suffix)
+ return suffixes, frozenset(exact), tuple(regexes), invalid_regexes
diff --git a/lite/rrpp_core/settings_store.py b/lite/rrpp_core/settings_store.py
new file mode 100644
index 0000000..f45c50e
--- /dev/null
+++ b/lite/rrpp_core/settings_store.py
@@ -0,0 +1,34 @@
+"""用户设置的 JSON 存储实现。"""
+
+import json
+import os
+
+
+class JsonSettingsStore:
+ """读取与原子写回一个 JSON 对象。
+
+ ``path_getter`` 在每次操作时调用,保证测试或运行时切换用户目录后不会
+ 意外写到旧目录。
+ """
+
+ def __init__(self, path_getter):
+ self._path_getter = path_getter
+
+ def load(self):
+ path = self._path_getter()
+ try:
+ if not path.is_file():
+ return {}
+ data = json.loads(path.read_text("utf-8"))
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ return {}
+
+ def save(self, data):
+ path = self._path_getter()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary_path = path.with_suffix(path.suffix + ".tmp")
+ temporary_path.write_text(
+ json.dumps(data, ensure_ascii=False, indent=2) + "\n", "utf-8"
+ )
+ os.replace(temporary_path, path)
diff --git a/lite/rrpp_core/traffic.py b/lite/rrpp_core/traffic.py
new file mode 100644
index 0000000..e2d2db8
--- /dev/null
+++ b/lite/rrpp_core/traffic.py
@@ -0,0 +1,55 @@
+"""线程安全的本地转发流量统计。"""
+
+import threading
+import time
+
+
+class TrafficStats:
+ """记录单次代理会话的上下行总量与采样速率。"""
+
+ def __init__(self, clock=time.time):
+ self._clock = clock
+ self._lock = threading.Lock()
+ self._up = 0
+ self._down = 0
+ self._prev_up = 0
+ self._prev_down = 0
+ self._prev_ts = 0.0
+
+ def add(self, up=0, down=0):
+ with self._lock:
+ self._up += up
+ self._down += down
+
+ def reset(self):
+ with self._lock:
+ self._up = self._down = 0
+ self._prev_up = self._prev_down = 0
+ self._prev_ts = self._clock()
+
+ def rate(self):
+ with self._lock:
+ now = self._clock()
+ if self._prev_ts > 0 and now > self._prev_ts:
+ elapsed = now - self._prev_ts
+ up_rate = int((self._up - self._prev_up) / elapsed)
+ down_rate = int((self._down - self._prev_down) / elapsed)
+ else:
+ up_rate = down_rate = 0
+ self._prev_up = self._up
+ self._prev_down = self._down
+ self._prev_ts = now
+ return up_rate, down_rate, self._up, self._down
+
+ def snapshot(self):
+ with self._lock:
+ return self._up, self._down
+
+
+def format_bytes(value):
+ """将字节数格式化为 UI 展示文本。"""
+ number = float(value)
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if number < 1024 or unit == "TB":
+ return f"{int(number)} {unit}" if unit == "B" else f"{number:.1f} {unit}"
+ number /= 1024
diff --git a/lite/rrpp_ui/__init__.py b/lite/rrpp_ui/__init__.py
new file mode 100644
index 0000000..397fac4
--- /dev/null
+++ b/lite/rrpp_ui/__init__.py
@@ -0,0 +1,4 @@
+"""RRPP Lite 的 Qt 界面层。
+
+核心代理与 PAC 逻辑不得依赖本包,以便后续在无 GUI 环境中独立测试。
+"""
diff --git a/lite/rrpp_ui/dialogs.py b/lite/rrpp_ui/dialogs.py
new file mode 100644
index 0000000..cc81155
--- /dev/null
+++ b/lite/rrpp_ui/dialogs.py
@@ -0,0 +1,468 @@
+"""需要业务服务的 Qt 对话框。"""
+
+import json
+import threading
+from dataclasses import dataclass
+
+from PyQt5.QtCore import Qt, pyqtSignal
+from PyQt5.QtWidgets import (
+ QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
+ QPushButton, QVBoxLayout, QWidget,
+)
+
+from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
+from .widgets import NodeLabel, ToggleSwitch
+
+
+@dataclass(frozen=True)
+class LoginServices:
+ """登录框所需的显式业务依赖,避免 UI 反向导入程序入口。"""
+
+ load_icon: object
+ load_auth: object
+ get_nodes_url: object
+ fetch_nodes: object
+ user_dir: object
+ encrypt_nodes: object
+ save_auth: object
+ auth_error: type
+ log: object
+
+
+class LoginDialog(QDialog):
+ """使用 SSO 获取并加密保存节点配置的登录框。"""
+
+ _done = pyqtSignal(bool, str)
+
+ def __init__(self, services: LoginServices, parent=None):
+ super().__init__(parent)
+ self._services = services
+ self.setWindowTitle("节点配置登录")
+ self.setWindowIcon(services.load_icon())
+ self.setFixedSize(420, 170)
+ 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: 6px 8px; color: {C_TEXT}; font-size: 12px;
+ }}
+ QPushButton#btnOk {{
+ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
+ padding: 4px 12px; font-size: 12px; font-weight: bold;
+ }}
+ QPushButton#btnOk:hover {{ background-color: #5ed978; }}
+ QPushButton#btnOk:disabled {{ background-color: #2a3a2e; color: #666; }}
+ QPushButton#btnCancel {{
+ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
+ border-radius: 6px; padding: 4px 12px; font-size: 12px;
+ }}
+ """)
+
+ root = QHBoxLayout(self)
+ root.setSpacing(14)
+ root.setContentsMargins(20, 18, 20, 16)
+ left = QVBoxLayout()
+ left.setSpacing(6)
+ left.addStretch()
+ logo = QLabel()
+ logo.setPixmap(services.load_icon().pixmap(80, 80))
+ logo.setFixedSize(80, 80)
+ logo.setAlignment(Qt.AlignCenter)
+ left.addWidget(logo, 0, Qt.AlignHCenter)
+ brand = QLabel("R.R.P.P.")
+ brand.setStyleSheet(f"color: {C_TITLE}; font-size: 10px;")
+ brand.setAlignment(Qt.AlignCenter)
+ left.addWidget(brand, 0, Qt.AlignHCenter)
+ left.addStretch()
+ root.addLayout(left)
+
+ form = QVBoxLayout()
+ form.setSpacing(6)
+ self.ed_user = QLineEdit()
+ self.ed_user.setPlaceholderText("SSO 邮箱")
+ form.addWidget(self.ed_user)
+ self.ed_pass = QLineEdit()
+ self.ed_pass.setPlaceholderText("密码")
+ self.ed_pass.setEchoMode(QLineEdit.Password)
+ self.ed_pass.returnPressed.connect(self._submit)
+ form.addWidget(self.ed_pass)
+ credentials = services.load_auth()
+ if credentials:
+ self.ed_user.setText(credentials["username"])
+ self.ed_pass.setText(credentials["password"])
+
+ self.lb_err = QLabel("")
+ self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
+ self.lb_err.setFixedHeight(14)
+ self.lb_err.hide()
+ form.addWidget(self.lb_err)
+ bottom = QHBoxLayout()
+ bottom.setSpacing(10)
+ hint = QLabel("登录以获取节点配置")
+ hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 10px;")
+ bottom.addWidget(hint)
+ bottom.addStretch()
+ self.btn_cancel = QPushButton("取消")
+ self.btn_cancel.setObjectName("btnCancel")
+ self.btn_cancel.clicked.connect(self.reject)
+ bottom.addWidget(self.btn_cancel)
+ self.btn_ok = QPushButton("登录")
+ self.btn_ok.setObjectName("btnOk")
+ self.btn_ok.clicked.connect(self._submit)
+ bottom.addWidget(self.btn_ok)
+ form.addLayout(bottom)
+ box = QWidget()
+ box.setLayout(form)
+ root.addWidget(box, 1, Qt.AlignVCenter)
+ self._done.connect(self._on_done)
+
+ def _submit(self):
+ user = self.ed_user.text().strip()
+ password = self.ed_pass.text()
+ if not user or not password:
+ self.lb_err.setText("请输入用户名和密码")
+ self.lb_err.show()
+ return
+ self.btn_ok.setEnabled(False)
+ self.btn_cancel.setEnabled(False)
+ self.btn_ok.setText("登录中")
+ threading.Thread(target=self._worker, args=(user, password), daemon=True).start()
+
+ def _worker(self, user, password):
+ try:
+ url = str(self._services.get_nodes_url() or "").strip()
+ if not url:
+ raise RuntimeError("config.json 未配置 nodes_url")
+ data = self._services.fetch_nodes(url, user, password)
+ if not (isinstance(data, list) and data and all(isinstance(node, dict) for node in data)
+ and str(data[0].get("domain") or "").strip()):
+ raise ValueError("返回的节点数据无效")
+ text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
+ directory = self._services.user_dir()
+ directory.mkdir(parents=True, exist_ok=True)
+ (directory / "nodes.json").write_bytes(self._services.encrypt_nodes(text))
+ self._services.save_auth(user, password)
+ self._done.emit(True, str(len(data)))
+ except self._services.auth_error as error:
+ self._done.emit(False, str(error))
+ except Exception as error:
+ self._done.emit(False, f"登录失败: {error}")
+
+ def _on_done(self, ok, info):
+ self.btn_ok.setEnabled(True)
+ self.btn_cancel.setEnabled(True)
+ self.btn_ok.setText("登录")
+ if ok:
+ self._services.log(f"登录成功,已获取 {info} 个节点(nodes.json 已加密保存)")
+ self.accept()
+ else:
+ self.lb_err.setText(info)
+ self.lb_err.show()
+
+
+@dataclass(frozen=True)
+class WhitelistServices:
+ """白名单编辑框所需的规则服务。"""
+
+ load_icon: object
+ load_rules: object
+ validate_rule: object
+ save_rules: object
+
+
+class WhitelistDialog(QDialog):
+ """编辑强制直连白名单;规则数据通过显式服务读写。"""
+
+ def __init__(self, services: WhitelistServices, parent=None):
+ super().__init__(parent)
+ self._services = services
+ self.setWindowTitle("代理白名单")
+ self.setWindowIcon(services.load_icon())
+ self.setFixedSize(380, 400)
+ 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: 6px 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: 3px 6px; 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: 4px 12px; font-size: 12px; font-weight: bold; }}
+ QPushButton#btnOk:hover {{ background-color: #5ed978; }}
+ QPushButton#btnCancel {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
+ border-radius: 6px; padding: 4px 12px; font-size: 12px; }}
+ QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
+ border-radius: 6px; padding: 3px 10px; font-size: 11px; }}
+ QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
+ """)
+ layout = QVBoxLayout(self)
+ layout.setSpacing(10)
+ 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("命中以下规则的连接将强制直连(不经过代理)")
+ hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
+ hint.setAlignment(Qt.AlignCenter)
+ layout.addWidget(hint)
+ self.lb_err = QLabel("")
+ self.lb_err.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
+ self.lb_err.setFixedHeight(14)
+ self.lb_err.hide()
+ layout.addWidget(self.lb_err)
+ self.list_widget = QListWidget()
+ self.list_widget.setToolTip("勾选启用规则;可多选后删除")
+ for rule in services.load_rules():
+ self._add_item(rule["value"], rule.get("enabled", True))
+ layout.addWidget(self.list_widget, 1)
+ add_row = QHBoxLayout()
+ add_row.setSpacing(8)
+ self.ed_add = QLineEdit()
+ self.ed_add.setPlaceholderText("输入域名 / IP / CIDR,如 example.com")
+ self.ed_add.returnPressed.connect(self._add_rule)
+ add_row.addWidget(self.ed_add, 1)
+ self.btn_add = QPushButton("添加")
+ self.btn_add.setObjectName("btnOk")
+ self.btn_add.setStyleSheet(f"""
+ QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
+ padding: 6px 14px; font-size: 12px; font-weight: bold; }}
+ QPushButton:hover {{ background-color: #5ed978; }}
+ """)
+ self.btn_add.clicked.connect(self._add_rule)
+ add_row.addWidget(self.btn_add)
+ layout.addLayout(add_row)
+ middle = QHBoxLayout()
+ self.btn_delete = QPushButton("删除选中")
+ self.btn_delete.setObjectName("btnSmall")
+ self.btn_delete.clicked.connect(self._delete_selected)
+ middle.addWidget(self.btn_delete)
+ middle.addStretch()
+ layout.addLayout(middle)
+ buttons = QHBoxLayout()
+ buttons.setSpacing(10)
+ buttons.addStretch()
+ self.btn_cancel = QPushButton("取消")
+ self.btn_cancel.setObjectName("btnCancel")
+ self.btn_cancel.clicked.connect(self.reject)
+ buttons.addWidget(self.btn_cancel)
+ self.btn_ok = QPushButton("保存")
+ self.btn_ok.setObjectName("btnOk")
+ self.btn_ok.clicked.connect(self._save)
+ buttons.addWidget(self.btn_ok)
+ layout.addLayout(buttons)
+
+ def _add_item(self, value, enabled):
+ item = QListWidgetItem(value)
+ item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
+ item.setCheckState(Qt.Checked if enabled else Qt.Unchecked)
+ self.list_widget.addItem(item)
+
+ def _add_rule(self):
+ value, error = self._services.validate_rule(self.ed_add.text())
+ if error:
+ self.lb_err.setText(error)
+ self.lb_err.show()
+ return
+ for index in range(self.list_widget.count()):
+ if self.list_widget.item(index).text().lower() == value.lower():
+ self.lb_err.setText("该规则已存在")
+ self.lb_err.show()
+ return
+ self.lb_err.hide()
+ self._add_item(value, True)
+ self.ed_add.clear()
+
+ def _delete_selected(self):
+ for item in self.list_widget.selectedItems():
+ self.list_widget.takeItem(self.list_widget.row(item))
+
+ def _save(self):
+ rules = []
+ for index in range(self.list_widget.count()):
+ item = self.list_widget.item(index)
+ value = str(item.text()).strip()
+ if value:
+ rules.append({"value": value, "enabled": item.checkState() == Qt.Checked})
+ self._services.save_rules(rules)
+ self.accept()
+
+
+@dataclass(frozen=True)
+class SettingsServices:
+ """设置框所需的配置与业务回调。"""
+
+ load_icon: object
+ version: str
+ get_port: object
+ get_update_via_proxy: object
+ get_skip_update_check: object
+ parse_port: object
+ set_port: object
+ set_update_via_proxy: object
+ set_skip_update_check: object
+ open_whitelist: object
+ logout: object
+
+
+class SettingsDialog(QDialog):
+ """代理端口、更新方式、白名单与登录状态设置框。"""
+
+ def __init__(self, services: SettingsServices, parent=None, port_locked=False):
+ super().__init__(parent)
+ self._services = services
+ self.setWindowTitle("设置")
+ self.setWindowIcon(services.load_icon())
+ self.setFixedSize(300, 354)
+ 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: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
+ """)
+ layout = QVBoxLayout(self)
+ layout.setSpacing(12)
+ 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)
+
+ version_row = QHBoxLayout()
+ version_row.addStretch()
+ version_label = QLabel(f"版本号: {services.version}")
+ version_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
+ version_row.addWidget(version_label)
+ version_row.addSpacing(4)
+ self.link_check_update = NodeLabel(
+ "检查更新", tooltip="手动检查更新",
+ base=f"color: {C_SUBTEXT}; font-size: 11px;",
+ hover=f"color: {C_TEXT}; font-size: 11px; text-decoration: underline;",
+ )
+ self.link_check_update.clicked.connect(self._manual_check_update)
+ version_row.addWidget(self.link_check_update)
+ version_row.addStretch()
+ layout.addLayout(version_row)
+
+ self.lb_hint = QLabel("代理运行中,关闭代理后可修改" if port_locked else "保存更改后下次开启代理生效")
+ self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
+ self.lb_hint.setFixedHeight(14)
+ self.lb_hint.setAlignment(Qt.AlignCenter)
+ layout.addWidget(self.lb_hint)
+ layout.addStretch()
+
+ whitelist_row = QHBoxLayout()
+ whitelist_label = QLabel("代理白名单")
+ whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
+ whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
+ whitelist_row.addWidget(whitelist_label)
+ whitelist_row.addStretch()
+ self.btn_whitelist = QPushButton("配置")
+ self.btn_whitelist.setCursor(Qt.PointingHandCursor)
+ self.btn_whitelist.setStyleSheet(f"""
+ QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
+ border-radius: 6px; padding: 2px 10px; font-size: 11px; }}
+ QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
+ """)
+ self.btn_whitelist.clicked.connect(self._open_whitelist)
+ whitelist_row.addWidget(self.btn_whitelist)
+ layout.addLayout(whitelist_row)
+
+ port_row = QHBoxLayout()
+ port_label = QLabel("代理转发端口")
+ port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
+ port_row.addWidget(port_label)
+ port_row.addStretch()
+ self.ed_port = QLineEdit(str(services.get_port()))
+ self.ed_port.setFixedSize(54, 22)
+ self.ed_port.setEnabled(not port_locked)
+ port_row.addWidget(self.ed_port)
+ layout.addLayout(port_row)
+
+ update_row = QHBoxLayout()
+ update_label = QLabel("使用代理更新")
+ update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
+ update_row.addWidget(update_label)
+ update_row.addStretch()
+ self.cb_update_proxy = ToggleSwitch(checked=services.get_update_via_proxy())
+ self.cb_update_proxy.setToolTip("开启后检查更新与下载走节点隧道(CONNECT);默认直连仓库")
+ update_row.addWidget(self.cb_update_proxy)
+ layout.addLayout(update_row)
+
+ skip_row = QHBoxLayout()
+ skip_label = QLabel("跳过检查更新")
+ skip_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
+ skip_row.addWidget(skip_label)
+ skip_row.addStretch()
+ self.cb_skip_update = ToggleSwitch(checked=services.get_skip_update_check())
+ self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
+ skip_row.addWidget(self.cb_skip_update)
+ layout.addLayout(skip_row)
+ layout.addStretch()
+
+ buttons = QHBoxLayout()
+ buttons.setSpacing(10)
+ buttons.addStretch()
+ self.btn_save = QPushButton("保存更改")
+ self.btn_save.setStyleSheet(f"""
+ QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
+ padding: 5px 14px; font-size: 12px; font-weight: bold; }}
+ QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }}
+ """)
+ 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.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.clicked.connect(self._logout)
+ buttons.addWidget(self.btn_logout)
+ buttons.addStretch()
+ layout.addLayout(buttons)
+
+ def _manual_check_update(self):
+ parent = self.parent()
+ if not (hasattr(parent, "_check_update") and hasattr(parent, "_update_done")):
+ return
+ self.lb_hint.setText("正在检查更新...")
+ self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
+ try:
+ parent._update_done.connect(self._on_check_done)
+ except TypeError:
+ pass
+ threading.Thread(target=parent._check_update, kwargs={"manual": True}, daemon=True).start()
+
+ def _on_check_done(self):
+ self.lb_hint.setText("检查完成,结果见主界面日志")
+ self.lb_hint.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 11px;")
+ try:
+ self.parent()._update_done.disconnect(self._on_check_done)
+ except Exception:
+ pass
+
+ def _open_whitelist(self):
+ self._services.open_whitelist(self)
+
+ def _save_settings(self):
+ port = self._services.parse_port(self.ed_port.text())
+ if port is None:
+ self.lb_hint.setText("端口应为 1-65535 的数字")
+ self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
+ return
+ self._services.set_port(port)
+ self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
+ self._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
+ self.ed_port.setText(str(port))
+ self.lb_hint.setText("已保存,下次开启代理生效")
+ self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
+
+ def _logout(self):
+ self._services.logout()
+ self.accept()
diff --git a/lite/rrpp_ui/theme.py b/lite/rrpp_ui/theme.py
new file mode 100644
index 0000000..3691214
--- /dev/null
+++ b/lite/rrpp_ui/theme.py
@@ -0,0 +1,11 @@
+"""RRPP Lite 界面的共享色彩常量。"""
+
+C_TITLE = "#8899aa"
+C_GREEN = "#51cf66"
+C_YELLOW = "#ffd43b"
+C_RED = "#ff6b6b"
+C_BG = "#ffffff"
+C_CARD = "#f0f2f5"
+C_BORDER = "#d0d5dd"
+C_TEXT = "#333333"
+C_SUBTEXT = "#889099"
diff --git a/lite/rrpp_ui/widgets.py b/lite/rrpp_ui/widgets.py
new file mode 100644
index 0000000..20ed0c6
--- /dev/null
+++ b/lite/rrpp_ui/widgets.py
@@ -0,0 +1,104 @@
+"""无业务状态的可复用 Qt 控件。"""
+
+from PyQt5.QtCore import Qt, QRectF, pyqtSignal
+from PyQt5.QtGui import QColor, QPainter
+from PyQt5.QtWidgets import QCheckBox, QLabel
+
+from .theme import C_BORDER, C_GREEN, C_RED, C_TITLE
+
+
+_NL_BASE = f"color: {C_TITLE}; font-size: 12px;"
+
+
+class NodeLabel(QLabel):
+ """可点击标签:hover 显示下划线和手型光标,点击发出 clicked 信号。"""
+
+ clicked = pyqtSignal()
+
+ def __init__(self, text="", tooltip="点击切换节点", base=None, hover=None):
+ super().__init__(text)
+ self._base = base or _NL_BASE
+ self._hover = hover or self._base + " text-decoration: underline;"
+ self.setStyleSheet(self._base)
+ if tooltip:
+ self.setToolTip(tooltip)
+
+ def enterEvent(self, event):
+ self.setStyleSheet(self._hover)
+ self.setCursor(Qt.PointingHandCursor)
+ super().enterEvent(event)
+
+ def leaveEvent(self, event):
+ self.setStyleSheet(self._base)
+ self.unsetCursor()
+ super().leaveEvent(event)
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.clicked.emit()
+ super().mouseReleaseEvent(event)
+
+
+class ToggleSwitch(QCheckBox):
+ """苹果风格开关:绿/灰胶囊加滑动圆钮,自绘且不依赖图片资源。"""
+
+ def __init__(self, checked=False, parent=None):
+ super().__init__(parent)
+ self.setChecked(checked)
+ self.setFixedSize(42, 22)
+ self.setCursor(Qt.PointingHandCursor)
+
+ def paintEvent(self, event):
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.Antialiasing)
+ painter.setPen(Qt.NoPen)
+ painter.setBrush(QColor(C_GREEN) if self.isChecked() else QColor("#c8ccd4"))
+ painter.drawRoundedRect(QRectF(0, 0, 42, 22), 11, 11)
+ knob_x = 20.0 if self.isChecked() else 2.0
+ painter.setBrush(QColor("#ffffff"))
+ painter.drawEllipse(QRectF(knob_x, 2.0, 18.0, 18.0))
+ painter.end()
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.setChecked(not self.isChecked())
+ self.repaint()
+ event.accept()
+ return
+ super().mousePressEvent(event)
+
+
+class StatusDot(QLabel):
+ """代理状态小圆点。"""
+
+ def __init__(self):
+ super().__init__()
+ self.setFixedSize(14, 14)
+ self.set_off()
+
+ def set_on(self):
+ self.setStyleSheet(f"""
+ QLabel {{
+ background-color: {C_GREEN};
+ border-radius: 7px;
+ border: 1px solid rgba(81, 207, 102, 50);
+ }}
+ """)
+
+ def set_off(self):
+ self.setStyleSheet(f"""
+ QLabel {{
+ background-color: {C_RED};
+ border-radius: 7px;
+ border: 1px solid rgba(255, 107, 107, 50);
+ }}
+ """)
+
+ def set_disabled(self):
+ self.setStyleSheet(f"""
+ QLabel {{
+ background-color: {C_BORDER};
+ border-radius: 7px;
+ border: 1px solid rgba(136, 153, 170, 50);
+ }}
+ """)
diff --git a/lite/test.bat b/lite/test.bat
new file mode 100644
index 0000000..02f5b53
--- /dev/null
+++ b/lite/test.bat
@@ -0,0 +1,4 @@
+@echo off
+cd /d "%~dp0"
+call ..\.venv\Scripts\python.exe main.py
+pause
diff --git a/lite/test/PyQt5/QtCore.py b/lite/test/PyQt5/QtCore.py
new file mode 100644
index 0000000..a72f50f
--- /dev/null
+++ b/lite/test/PyQt5/QtCore.py
@@ -0,0 +1,10 @@
+class Qt: pass
+class QTimer: pass
+
+class _Signal:
+ def connect(self, *a, **k): pass
+ def emit(self, *a, **k): pass
+
+def pyqtSignal(*a, **k):
+ return _Signal()
+class QRectF: pass
diff --git a/lite/test/PyQt5/QtGui.py b/lite/test/PyQt5/QtGui.py
new file mode 100644
index 0000000..bfc830d
--- /dev/null
+++ b/lite/test/PyQt5/QtGui.py
@@ -0,0 +1,6 @@
+class QFont: pass
+class QIcon: pass
+class QPixmap: pass
+class QImage: pass
+class QColor: pass
+class QPainter: pass
diff --git a/lite/test/PyQt5/QtWidgets.py b/lite/test/PyQt5/QtWidgets.py
new file mode 100644
index 0000000..3c978ee
--- /dev/null
+++ b/lite/test/PyQt5/QtWidgets.py
@@ -0,0 +1,28 @@
+class QApplication: pass
+class QWidget: pass
+class QLabel:
+ def __init__(self, text=""):
+ self.text = text
+ def setStyleSheet(self, *a): pass
+ def setToolTip(self, *a): pass
+ def setFixedSize(self, *a): pass
+ def setFixedHeight(self, *a): pass
+ def setAlignment(self, *a): pass
+ def setPixmap(self, *a): pass
+ def setText(self, *a): pass
+ def setCursor(self, *a): pass
+ def unsetCursor(self): pass
+ def hide(self): pass
+ def show(self): pass
+class QPushButton: pass
+class QVBoxLayout: pass
+class QHBoxLayout: pass
+class QFrame: pass
+class QMessageBox: pass
+class QTextEdit: pass
+class QCheckBox: pass
+class QMenu: pass
+class QDialog: Accepted = 1
+class QLineEdit: Password = 2
+class QListWidget: pass
+class QListWidgetItem: pass
diff --git a/lite/test/PyQt5/__init__.py b/lite/test/PyQt5/__init__.py
new file mode 100644
index 0000000..0436b2f
--- /dev/null
+++ b/lite/test/PyQt5/__init__.py
@@ -0,0 +1,2 @@
+# PyQt5 桩模块:仅供 WSL 下 import main 做路由逻辑测试,
+# 决策函数(_decide_direct 等)不依赖任何 Qt 对象。
diff --git a/lite/test/check_node_menu.py b/lite/test/check_node_menu.py
index 131fcdd..92fd61d 100644
--- a/lite/test/check_node_menu.py
+++ b/lite/test/check_node_menu.py
@@ -16,15 +16,19 @@ sys.path = [str(LITE)] + [p for p in sys.path if Path(p).resolve() != Path(__fil
sys.stdout.reconfigure(encoding="utf-8")
-import proxy_lite as pp
+import main as pp
from PyQt5.QtWidgets import QApplication
-# 构造窗口时不写用户数据目录(避免污染),构造后再指向真实 nodes.json
+# 构造窗口时不触发用户数据初始化或远程同步;本测试只验证静态菜单和本地节点状态。
pp._user_init_local = lambda: None
+pp._user_init_remote = lambda *args, **kwargs: None
+pp._nodes_sync_remote = lambda *args, **kwargs: pp._NODES_SYNC_DONE.set()
+pp._NODES_SYNC_DONE.set()
app = QApplication(sys.argv)
w = pp.MainWindow()
w.timer.stop() # 停掉状态轮询,纯静态检查
+failures = []
pp._USER_DIR = LITE / "test" # 真实 nodes.json 所在目录
nodes = pp._reload_nodes()
@@ -44,6 +48,8 @@ for act in menu.actions():
if not act.isEnabled():
act.trigger()
print(f"\n点击禁用项后节点下标: {before} -> {pp._selected_index()} (应不变)")
+if pp._selected_index() != before:
+ failures.append("点击不可用节点后发生了切换")
# ── 上次选中的节点已不可用:启动时自动回退到第一个可达节点 ──────────
print("\n启动校验(_verify_selected_node):")
@@ -54,19 +60,28 @@ else:
import json
_bak = (pp._USER_DIR / "settings.json").read_bytes() if (pp._USER_DIR / "settings.json").is_file() else None
bad_idx = bad[0]
- pp._SELECTED_NODE = bad_idx # 模拟上次选了不可用节点
+ # 模拟上次持久化选中了不可用节点:内存下标与 current_node 必须一致,
+ # 否则 _verify_selected_node 会先按真实保存的域名重新匹配,测试并非回退分支。
+ pp._settings_save({**pp._settings_load(), "current_node": nodes[bad_idx].get("domain") or ""})
+ pp._SELECTED_NODE = bad_idx
switched = pp._verify_selected_node()
expect = pp._first_reachable_index(nodes)
ok = switched and pp._selected_index() == expect
print(f" 上次选中 {nodes[bad_idx].get('domain')}(下标 {bad_idx}) 不可用 -> "
f"回退到下标 {pp._selected_index()} ({nodes[pp._selected_index()].get('domain')}) "
f"{'PASS' if ok else 'FAIL'}")
+ if not ok:
+ failures.append("不可用节点未回退到首个可达节点")
saved = json.loads((pp._USER_DIR / "settings.json").read_text("utf-8")).get("current_node") if (pp._USER_DIR / "settings.json").is_file() else ""
ok2 = saved == str(nodes[expect].get("domain") or "")
print(f" 回退结果已写入设置 current_node={saved!r} {'PASS' if ok2 else 'FAIL'}")
+ if not ok2:
+ failures.append("节点回退未写回 current_node")
# 再校验一次:已经是可达节点,不应再切换
ok3 = pp._verify_selected_node() is False
print(f" 再次校验不重复切换 {'PASS' if ok3 else 'FAIL'}")
+ if not ok3:
+ failures.append("已回退节点被重复切换")
if _bak is None:
(pp._USER_DIR / "settings.json").unlink(missing_ok=True)
else:
@@ -87,13 +102,19 @@ else:
ok = switched and ordered[pp._selected_index()].get("domain") == dom_old
print(f" current_node={dom_old} 调序后跟随域名 -> 下标 {pp._selected_index()} "
f"{'PASS' if ok else 'FAIL'}")
+ if not ok:
+ failures.append("节点调序后没有按域名重新匹配")
# 已是正确域名时再跑一次:不应重复切换
ok2 = pp._rematch_saved_domain() is False
print(f" 再次重匹配不重复切换 {'PASS' if ok2 else 'FAIL'}")
+ if not ok2:
+ failures.append("节点调序重匹配重复切换")
# 域名已不在列表(节点被移除):不切换,保持现状
pp._settings_save({**pp._settings_load(), "current_node": "gone.example.com"})
ok3 = pp._rematch_saved_domain() is False
print(f" 节点被移除时不动 {'PASS' if ok3 else 'FAIL'}")
+ if not ok3:
+ failures.append("已移除节点仍发生切换")
pp._NODES = None # 还原缓存,后续用例重读文件
# 还原 settings.json,避免测试污染
if _bak is None:
@@ -101,4 +122,6 @@ else:
else:
(pp._USER_DIR / "settings.json").write_bytes(_bak)
+if failures:
+ raise SystemExit("FAIL: " + "; ".join(failures))
print("OK")
diff --git a/lite/test/nodes.json b/lite/test/nodes.json
new file mode 100644
index 0000000..e01510f
--- /dev/null
+++ b/lite/test/nodes.json
@@ -0,0 +1,21 @@
+[
+ {
+ "domain": "example.com",
+ "port": 443,
+ "country": "TEST",
+ "remark": "普通测试节点"
+ },
+ {
+ "domain": "example.org",
+ "port": 443,
+ "country": "TEST",
+ "remark": "备用测试节点"
+ },
+ {
+ "domain": "ipv6.test.invalid",
+ "port": 443,
+ "country": "TEST",
+ "ipv6_only": true,
+ "remark": "IPv6 测试节点"
+ }
+]
diff --git a/lite/test/probe_nodes.py b/lite/test/probe_nodes.py
new file mode 100644
index 0000000..f8115f0
--- /dev/null
+++ b/lite/test/probe_nodes.py
@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+"""连通性探测:用 test/nodes.json 的真实节点,走 _tls_start 转发逻辑实测。
+
+仅需标准库;不启动 GUI(main 的 GUI 在 __main__ 保护下)。
+用法: python3 test/probe_nodes.py [JP US ...] 不带参数测全部
+"""
+import os, sys, time, json, ssl, socket, urllib.request, urllib.error
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+LITE = HERE.parent
+sys.path.insert(0, str(HERE)) # PyQt5 桩
+sys.path.insert(0, str(LITE))
+
+for k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
+ os.environ.pop(k, None)
+# 不能设 NO_PROXY=*:urllib 会把 127.0.0.1 也当直连,显式 ProxyHandler 被绕过
+os.environ["NO_PROXY"] = ""
+
+import main as pp
+
+NODES = json.loads((HERE / "nodes.json").read_text("utf-8"))
+want = [a.upper() for a in sys.argv[1:]]
+if want:
+ NODES = [n for n in NODES if str(n.get("country", "")).upper() in want]
+
+TIMEOUT = 12
+
+
+def free_port():
+ s = socket.socket(); s.bind(("127.0.0.1", 0))
+ p = s.getsockname()[1]; s.close(); return p
+
+
+def http_method(node):
+ return (node.get("methods") or {}).get("http") or {}
+
+
+def as_flat_node(node):
+ """把 nodes.json 的 methods.http 结构摊平成代码 _node_val() 期望的形状。
+ 仅用于探测:暴露「代码读不到真实节点字段」这一问题。"""
+ m = http_method(node)
+ return dict(node, port=int(m.get("port")), username=m.get("username", ""),
+ password=m.get("password", ""))
+
+
+def probe_connect(host, port, sni=None, connect_timeout=8):
+ """直连探测:DNS -> TCP -> TLS -> 发 CONNECT 127.0.0.1:9 看上游代理响应。"""
+ res = {"dns": None, "tcp": None, "tls": None, "proxy": None, "err": None}
+ t0 = time.time()
+ try:
+ infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
+ res["dns"] = sorted({i[4][0] for i in infos})
+ except Exception as e:
+ res["err"] = f"DNS: {e}"
+ return res
+ addr = res["dns"][0]
+ af = socket.AF_INET6 if ":" in addr else socket.AF_INET
+ try:
+ s = socket.socket(af, socket.SOCK_STREAM)
+ s.settimeout(connect_timeout)
+ s.connect((addr, port))
+ res["tcp"] = round(time.time() - t0, 2)
+ except Exception as e:
+ res["err"] = f"TCP: {e}"
+ return res
+ try:
+ ctx = ssl.create_default_context()
+ s = ctx.wrap_socket(s, server_hostname=sni or host)
+ res["tls"] = s.version()
+ except Exception as e:
+ res["err"] = f"TLS: {e}"
+ try: s.close()
+ except Exception: pass
+ return res
+ try:
+ s.sendall(b"CONNECT 127.0.0.1:9 HTTP/1.1\r\nHost: 127.0.0.1:9\r\n\r\n")
+ res["proxy"] = s.recv(200).split(b"\r\n", 1)[0].decode("latin-1", "replace")
+ except Exception as e:
+ res["err"] = f"CONNECT: {e}"
+ try: s.close()
+ except Exception: pass
+ return res
+
+
+def probe_via_forwarder(node):
+ """真实转发器链路:_tls_start -> 经 127.0.0.1 代理请求,取出口 IP 与归属地。"""
+ m = http_method(node)
+ if not m:
+ return "(该节点无 http 方法,跳过)"
+ old_node, old_port = pp._current_node, pp._TLS_LOCAL_PORT
+ port = free_port()
+ try:
+ # _proxy_auth_basic() 内部走 _current_node(),固定成本次待测节点
+ pp._current_node = lambda: node
+ pp._TLS_LOCAL_PORT = port
+ pp._tls_stop()
+ pp._tls_start(node["domain"], int(m["port"]), use_tls=True,
+ bypass_lan=False, smart=False, quiet=True)
+ ph = urllib.request.ProxyHandler({"http": f"127.0.0.1:{port}",
+ "https": f"127.0.0.1:{port}"})
+ op = urllib.request.build_opener(ph)
+ t0 = time.time()
+ # 明文 HTTP 代理(走 GET 绝对 URI)与 CONNECT 隧道各测一条
+ with op.open("http://ip-api.com/json/?fields=query,country,regionName,isp",
+ timeout=TIMEOUT) as r:
+ info = json.loads(r.read(512).decode("utf-8", "replace"))
+ with op.open("https://ifconfig.me/ip", timeout=TIMEOUT) as r:
+ https_ip = r.read(64).decode().strip()
+ dt = round(time.time() - t0, 2)
+ return (f"OK {dt}s 出口IP={info.get('query')} "
+ f"[{info.get('country')} {info.get('regionName')} / {info.get('isp')}] "
+ f"HTTPS隧道出口={https_ip}")
+ except urllib.error.HTTPError as e:
+ return f"HTTP {e.code}: {e.read(120).decode('utf-8', 'replace').strip()[:100]}"
+ except Exception as e:
+ return f"失败: {type(e).__name__}: {e}"
+ finally:
+ pp._tls_stop()
+ pp._current_node, pp._TLS_LOCAL_PORT = old_node, old_port
+
+
+print(f"节点数: {len(NODES)}")
+n0 = NODES[0]
+print(f"代码 _endpoint_domain()={pp._endpoint_domain()!r} "
+ f"_endpoint_port()={pp._endpoint_port()!r} _endpoint_country()={pp._endpoint_country()!r}")
+print(f"代码 _proxy_auth_basic()={pp._proxy_auth_basic()!r}")
+print(f"nodes.json[0] 顶层键 = {list(n0.keys())}")
+print(f"nodes.json[0] domain={n0.get('domain')!r} methods.http.port="
+ f"{http_method(n0).get('port')!r}")
+print()
+
+for n in NODES:
+ c = n.get("country", "?")
+ m = http_method(n)
+ print("=" * 78)
+ print(f"[{c}] {n.get('domain')}:{m.get('port')} provider={n.get('provider')} "
+ f"traffic={n.get('traffic')} level={n.get('available_level')}")
+ if not m:
+ print(" 该节点无 http 方法,跳过")
+ continue
+ r = probe_connect(n["domain"], int(m["port"]))
+ print(f" DNS : {r['dns']}")
+ print(f" TCP : {r['tcp']}s" if r["tcp"] is not None else " TCP : -")
+ print(f" TLS : {r['tls']}" if r.get("tls") else " TLS : -")
+ print(f" CONNECT: {r['proxy']}" if r.get("proxy") else " CONNECT: -")
+ if r.get("err"):
+ print(f" 错误 : {r['err'].splitlines()[0][:200]}")
+ print(f" 经转发器: {probe_via_forwarder(n)}")
+ print(f" 经转发器(摊平节点): {probe_via_forwarder(as_flat_node(n))}")
+ print()
diff --git a/lite/test/test_core_modules.py b/lite/test/test_core_modules.py
new file mode 100644
index 0000000..202f824
--- /dev/null
+++ b/lite/test/test_core_modules.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+"""拆分后 rrpp_core 的独立回归测试(不依赖 Qt 或真实代理连接)。"""
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+LITE_DIR = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(LITE_DIR))
+
+from rrpp_core.forwarder_primitives import (
+ force_http_close,
+ inject_proxy_auth,
+ parse_connect_authority,
+ parse_http_absolute,
+)
+from rrpp_core.node_api import NodeAuthError, b64url
+from rrpp_core.node_secrets import NodeSecretsStore
+from rrpp_core.network_primitives import is_private_host, is_private_ipv4_int, ttl_cache
+from rrpp_core.pac_data import atomic_write, read_version, validate_document
+from rrpp_core.routing_primitives import (
+ build_suffix_trie,
+ merge_ip_ranges,
+ parse_domain_rules,
+ suffix_trie_matches,
+)
+from rrpp_core.settings_store import JsonSettingsStore
+from rrpp_core.traffic import TrafficStats, format_bytes
+
+
+class CoreModuleTests(unittest.TestCase):
+ def test_domain_rules_and_trie(self):
+ suffixes, exact, regexes, invalid = parse_domain_rules(
+ ["example.com", "full:only.example", "regexp:^edge-[0-9]+\\.example$", "regexp:["]
+ )
+ self.assertEqual(invalid, 1)
+ self.assertIn("example.com", suffixes)
+ self.assertIn("only.example", exact)
+ self.assertTrue(regexes[0].search("edge-42.example"))
+ trie = build_suffix_trie(suffixes)
+ self.assertTrue(suffix_trie_matches("cdn.example.com", trie))
+ self.assertFalse(suffix_trie_matches("example.net", trie))
+ self.assertEqual(
+ merge_ip_ranges(["10.0.0.0/25", "10.0.0.128/25"], 4),
+ [(167772160, 167772415)],
+ )
+
+ def test_forwarder_primitives(self):
+ self.assertEqual(
+ parse_connect_authority("CONNECT [::1]:443 HTTP/1.1"), ("::1", 443)
+ )
+ self.assertEqual(
+ parse_http_absolute("GET https://example.com/path HTTP/1.1"),
+ ("example.com", 443),
+ )
+ request = b"GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
+ injected = inject_proxy_auth(request, "token")
+ self.assertIn(b"Authorization: Basic token\r\n", injected)
+ self.assertEqual(inject_proxy_auth(injected, "different"), injected)
+ self.assertIn(b"Connection: close\r\n", force_http_close(request))
+
+ def test_settings_pac_and_node_secrets_storage(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ settings = JsonSettingsStore(lambda: directory / "settings.json")
+ self.assertEqual(settings.load(), {})
+ settings.save({"local_port": 1080})
+ self.assertEqual(settings.load(), {"local_port": 1080})
+
+ payload = {"version": "1", "domains": ["example.com"]}
+ atomic_write(directory, "pac_domains", payload)
+ self.assertEqual(read_version(directory / "pac_domains.json"), ("1", ""))
+ self.assertTrue(validate_document("pac_domains", payload))
+
+ secrets = NodeSecretsStore(lambda: directory)
+ self.assertEqual(secrets.decrypt_bytes(secrets.encrypt_text("round-trip")), "round-trip")
+ secrets.save_auth("test-user", "test-password")
+ self.assertEqual(
+ secrets.load_auth(), {"username": "test-user", "password": "test-password"}
+ )
+
+ def test_traffic_and_node_protocol_helpers(self):
+ times = iter((10.0, 12.0))
+ stats = TrafficStats(clock=lambda: next(times))
+ stats.reset()
+ stats.add(up=20, down=10)
+ self.assertEqual(stats.rate(), (10, 5, 20, 10))
+ self.assertEqual(format_bytes(1536), "1.5 KB")
+ self.assertEqual(b64url("a/b="), "YS9iPQ")
+ error = NodeAuthError("denied", 403)
+ self.assertEqual((str(error), error.code), ("denied", 403))
+
+ def test_network_primitives_and_ttl_cache(self):
+ self.assertTrue(is_private_ipv4_int(0x0A010203))
+ self.assertTrue(is_private_host("::1"))
+ self.assertFalse(is_private_host("8.8.8.8"))
+ calls = []
+
+ @ttl_cache(300)
+ def probe(value):
+ calls.append(value)
+ return value
+
+ self.assertEqual((probe("x"), probe("x")), ("x", "x"))
+ self.assertEqual(calls, ["x"])
+ probe.cache_clear()
+ probe("x")
+ self.assertEqual(calls, ["x", "x"])
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/lite/test/test_pac_routing.py b/lite/test/test_pac_routing.py
index 2d5e328..be4eb07 100644
--- a/lite/test/test_pac_routing.py
+++ b/lite/test/test_pac_routing.py
@@ -3,13 +3,13 @@
核心问题:PAC 模式下,目标域名解析为国外 IP 时,是否强制走隧道代理?
-判定链(proxy_lite._decide_direct):
+判定链(main._decide_direct):
0. 代理白名单命中 -> 强制直连(最优先,PAC/固定模式均生效)
1. pac_domains.json 域名表命中 -> 强制隧道(优先于 IP 判定)
2. 内网 IP 且勾选 bypass_lan -> 直连
3. 智能分流:中国 IP -> 直连;其余(国外 IP)-> 走隧道
-直接 import proxy_lite 调用真实决策函数,数据用真实的 pac_ip_cn.json /
+直接 import main 调用真实决策函数,数据用真实的 pac_ip_cn.json /
pac_domains.json;PyQt5 用桩替代(决策逻辑不依赖 Qt)。
"""
@@ -25,8 +25,8 @@ sys.path.insert(0, str(LITE_DIR))
import json
import socket
-import proxy_lite as pp
-from proxy_lite import _decide_direct, _is_china_ip, _is_force_proxy, _is_private_ip, _is_whitelisted
+import main as pp
+from main import _decide_direct, _is_china_ip, _is_force_proxy, _is_private_ip, _is_whitelisted
# PAC 数据已不再随项目保留:从 CDN 拉取到临时用户目录(真实数据端到端)
import tempfile
@@ -241,10 +241,11 @@ if real_nodes:
print(f" [{c}] {n.get('domain'):<24} ipv6_only={str(io):<6} "
f"可达={str(reach):<6} 菜单文案: {n.get('domain')} ({c})"
+ (f" {mark}" if io else ""))
- # ipv6_only 节点在本机无 IPv6 时必须判定为不可达,否则必须可达
+ # ipv6_only 节点在本机无 IPv6 时必须判定为不可达;有 IPv6 时仍取
+ # 实际 TCP 探测结果(本机有 IPv6 不代表每个远端节点都必然在线)。
if io:
- expect = has_v6 or reach # 本机有 IPv6 -> 应可达;无 IPv6 -> 不应可达
- ok = reach == expect
+ expect = "实际探测" if has_v6 else False
+ ok = isinstance(reach, bool) if has_v6 else reach is False
ipv6_failed += 0 if ok else 1
print(f" IPv6 专用节点可达性: {reach} (期望 {expect}) "
f"{'PASS' if ok else 'FAIL'}")