Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4d777113c | ||
|
|
26926f6469 | ||
|
|
a0e2600399 | ||
|
|
01de67610d | ||
|
|
384a6107a8 | ||
|
|
fb47f9e1d2 | ||
|
|
71a67d7dca |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "999909181435",
|
"version": "999909182137",
|
||||||
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
"pac_base_url": "https://cdn.rrrosmontis.icu/RRPP/PAC/",
|
||||||
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
"nodes_url": "https://api.rrrosmontis.icu/v1/rrpp/subscription?method=rrpp_client",
|
||||||
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
"update_url": "https://git.rrrosmontis.icu/api/v1/repos/RoyirinRosmontis/Private-Proxy/releases/latest"
|
||||||
|
|||||||
+546
-58
@@ -1,12 +1,13 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid
|
import sys, os, subprocess, time, ctypes, threading, functools, base64, bisect, re, json, uuid
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication, QWidget, QLabel, QPushButton,
|
QApplication, QWidget, QLabel, QPushButton,
|
||||||
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
QVBoxLayout, QHBoxLayout, QFrame, QMessageBox, QTextEdit,
|
||||||
QCheckBox, QMenu, QDialog, QProgressDialog,
|
QCheckBox, QMenu, QDialog, QProgressDialog, QAction, QSystemTrayIcon,
|
||||||
)
|
)
|
||||||
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||||||
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
|
from PyQt5.QtGui import QFont, QIcon, QPixmap, QImage, QColor
|
||||||
@@ -14,8 +15,10 @@ 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.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.widgets import NodeLabel, StatusDot, ToggleSwitch
|
||||||
from rrpp_ui.dialogs import (
|
from rrpp_ui.dialogs import (
|
||||||
LoginDialog, LoginServices, SettingsDialog, SettingsServices, ThirdPartyDialog, ThirdPartyServices,
|
LoginDialog, LoginServices, SettingsDialog, SettingsServices, LogDialog, LogServices,
|
||||||
WhitelistDialog, WhitelistServices, JumpDialog, JumpServices,
|
ThirdPartyDialog, ThirdPartyServices,
|
||||||
|
WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices,
|
||||||
|
JumpDialog, JumpServices,
|
||||||
)
|
)
|
||||||
from rrpp_core.routing_primitives import (
|
from rrpp_core.routing_primitives import (
|
||||||
build_suffix_trie as _build_suffix_trie,
|
build_suffix_trie as _build_suffix_trie,
|
||||||
@@ -813,13 +816,19 @@ def _endpoint_label():
|
|||||||
domain = _endpoint_domain()
|
domain = _endpoint_domain()
|
||||||
if domain == "???":
|
if domain == "???":
|
||||||
return "无可用节点(请登录)"
|
return "无可用节点(请登录)"
|
||||||
return f"{domain} ({_endpoint_country()})"
|
display = _node_display_mode()
|
||||||
|
name = str(node.get("remark") or "").strip() if display == "remark" else ""
|
||||||
|
return f"{name or domain} ({_endpoint_country()})"
|
||||||
|
|
||||||
def _proxy_auth_basic():
|
def _proxy_auth_basic(node=None):
|
||||||
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
|
"""正向代理 Basic 认证凭证:methods.http.{username,password}(新格式)
|
||||||
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
|
或节点顶层同名字段(旧格式);用于转发器向上游代理注入 Authorization。"""
|
||||||
_, get = _http_method()
|
if node is None:
|
||||||
return base64.b64encode(f"{get('username')}:{get('password')}".encode()).decode()
|
_, get = _http_method()
|
||||||
|
username, password = get("username"), get("password")
|
||||||
|
else:
|
||||||
|
username, password = _node_proxy_credentials(node)
|
||||||
|
return base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||||
|
|
||||||
_tls_forwarder = None
|
_tls_forwarder = None
|
||||||
_pac_server = None
|
_pac_server = None
|
||||||
@@ -928,17 +937,62 @@ def _stop_pac_server():
|
|||||||
# ── 日志输出 ──────────────────────────────────────────
|
# ── 日志输出 ──────────────────────────────────────────
|
||||||
# GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit
|
# GUI 初始化后会把 _log_func 设为 MainWindow.log_signal.emit
|
||||||
_log_func = None
|
_log_func = None
|
||||||
|
_LOG_HISTORY_LIMIT = 2000
|
||||||
|
_LOG_HISTORY = []
|
||||||
|
_LOG_HISTORY_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_log_message(msg: str) -> str:
|
||||||
|
"""去掉红色警告日志的 HTML 包装,供日志弹窗显示纯文本。"""
|
||||||
|
return re.sub(r"<[^>]+>", "", str(msg))
|
||||||
|
|
||||||
|
|
||||||
|
def _record_log(msg: str) -> str:
|
||||||
|
"""记录带毫秒时间戳的日志并返回弹窗使用的纯文本行。"""
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||||
|
line = f"[{timestamp}] {_plain_log_message(msg)}"
|
||||||
|
with _LOG_HISTORY_LOCK:
|
||||||
|
_LOG_HISTORY.append(line)
|
||||||
|
if len(_LOG_HISTORY) > _LOG_HISTORY_LIMIT:
|
||||||
|
del _LOG_HISTORY[:-_LOG_HISTORY_LIMIT]
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _log_history() -> list[str]:
|
||||||
|
"""返回日志快照,避免日志弹窗读取时阻塞连接线程。"""
|
||||||
|
with _LOG_HISTORY_LOCK:
|
||||||
|
return list(_LOG_HISTORY)
|
||||||
|
|
||||||
def log(msg: str):
|
def log(msg: str):
|
||||||
"""输出一行日志到 GUI 日志区。"""
|
"""输出一行日志到 GUI 日志区。"""
|
||||||
|
_record_log(msg)
|
||||||
if _log_func:
|
if _log_func:
|
||||||
_log_func(msg)
|
_log_func(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def log_detail(msg: str):
|
||||||
|
"""仅写入详细日志历史,不打扰主界面的简要日志区。"""
|
||||||
|
_record_log(msg)
|
||||||
|
|
||||||
|
|
||||||
def log_red(msg: str):
|
def log_red(msg: str):
|
||||||
"""输出一行红色警告日志到 GUI 日志区。"""
|
"""输出一行红色警告日志到 GUI 日志区。"""
|
||||||
|
_record_log(msg)
|
||||||
if _log_func:
|
if _log_func:
|
||||||
_log_func(f'<span style="color:{C_RED};">{msg}</span>')
|
_log_func(f'<span style="color:{C_RED};">{msg}</span>')
|
||||||
|
|
||||||
|
|
||||||
|
_CONNECTION_ID = 0
|
||||||
|
_CONNECTION_ID_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_connection_id() -> int:
|
||||||
|
"""为每条转发连接分配单调递增的进程内编号。"""
|
||||||
|
global _CONNECTION_ID
|
||||||
|
with _CONNECTION_ID_LOCK:
|
||||||
|
_CONNECTION_ID += 1
|
||||||
|
return _CONNECTION_ID
|
||||||
|
|
||||||
# ── 通用工具函数 ──────────────────────────────────────
|
# ── 通用工具函数 ──────────────────────────────────────
|
||||||
|
|
||||||
def _run(cmd, warn=True):
|
def _run(cmd, warn=True):
|
||||||
@@ -1189,7 +1243,7 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
|
|||||||
suffix = f" [{route}]" if route else ""
|
suffix = f" [{route}]" if route else ""
|
||||||
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})")
|
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port}{suffix})")
|
||||||
|
|
||||||
def _inject_proxy_auth(data: bytes) -> bytes:
|
def _inject_proxy_auth(data: bytes, proxy_node=None) -> bytes:
|
||||||
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
|
"""在 HTTP 代理请求头中注入 Authorization(如果尚未存在)。
|
||||||
|
|
||||||
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
|
nginx 的 auth_basic 校验 Authorization 而非 Proxy-Authorization,
|
||||||
@@ -1199,12 +1253,12 @@ def _inject_proxy_auth(data: bytes) -> bytes:
|
|||||||
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
|
Authorization(明文 HTTP 请求)才跳过。注入位置紧跟请求行,
|
||||||
保证上游(取首个 Authorization)读到的是我们的认证。
|
保证上游(取首个 Authorization)读到的是我们的认证。
|
||||||
"""
|
"""
|
||||||
node, _ = _http_method()
|
node = proxy_node or _http_method()[0]
|
||||||
if not any(_node_proxy_credentials(node)):
|
if not any(_node_proxy_credentials(node)):
|
||||||
return data
|
return data
|
||||||
return _inject_proxy_auth_value(data, _proxy_auth_basic())
|
return _inject_proxy_auth_value(data, _proxy_auth_basic(node))
|
||||||
|
|
||||||
def _make_request_injector():
|
def _make_request_injector(proxy_node=None):
|
||||||
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
|
"""为明文 HTTP 代理连接生成逐条注入认证的流式处理器。
|
||||||
|
|
||||||
keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。
|
keep-alive 连接上的后续请求同样需要 Authorization,不能只在首包注入。
|
||||||
@@ -1220,7 +1274,7 @@ def _make_request_injector():
|
|||||||
if not _starts_with_method(data):
|
if not _starts_with_method(data):
|
||||||
return data
|
return data
|
||||||
if b"\r\n\r\n" in data or len(data) >= 65536:
|
if b"\r\n\r\n" in data or len(data) >= 65536:
|
||||||
return _inject_proxy_auth(data)
|
return _inject_proxy_auth(data, proxy_node)
|
||||||
pending = data
|
pending = data
|
||||||
return b""
|
return b""
|
||||||
return feed
|
return feed
|
||||||
@@ -1316,6 +1370,24 @@ def _set_local_port(port):
|
|||||||
_settings_save(data)
|
_settings_save(data)
|
||||||
log(f"代理转发端口已保存: {port}(下次开启代理生效)")
|
log(f"代理转发端口已保存: {port}(下次开启代理生效)")
|
||||||
|
|
||||||
|
def _use_builtin_pac():
|
||||||
|
"""是否使用固定本地代理承载系统流量、由转发器内部执行 PAC 判断。"""
|
||||||
|
return bool(_settings_load().get("builtin_pac", True))
|
||||||
|
|
||||||
|
def _set_builtin_pac(enabled):
|
||||||
|
"""保存内置 PAC 偏好,下一次开启代理时生效。"""
|
||||||
|
data = _settings_load()
|
||||||
|
data["builtin_pac"] = bool(enabled)
|
||||||
|
_settings_save(data)
|
||||||
|
|
||||||
|
def _should_use_system_pac(use_pac, use_builtin_pac):
|
||||||
|
"""决定系统侧是否下发 PAC;内置 PAC 只保留转发器内部智能分流。"""
|
||||||
|
return bool(use_pac and not use_builtin_pac)
|
||||||
|
|
||||||
|
def _should_use_internal_pac(use_pac, use_builtin_pac):
|
||||||
|
"""决定转发器内部是否启用 PAC 智能直连/代理判断。"""
|
||||||
|
return bool(use_pac or use_builtin_pac)
|
||||||
|
|
||||||
def _update_via_proxy():
|
def _update_via_proxy():
|
||||||
"""更新检查/下载是否走节点隧道(CONNECT);设置项,默认关=直连仓库。"""
|
"""更新检查/下载是否走节点隧道(CONNECT);设置项,默认关=直连仓库。"""
|
||||||
return bool(_settings_load().get("update_via_proxy", False))
|
return bool(_settings_load().get("update_via_proxy", False))
|
||||||
@@ -1339,38 +1411,228 @@ def _set_skip_update_check(enabled):
|
|||||||
_settings_save(data)
|
_settings_save(data)
|
||||||
log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)")
|
log(f"检查更新已{'跳过' if enabled else '恢复'}(PAC 与程序版本;节点更新不受影响)")
|
||||||
|
|
||||||
# ── 代理白名单(用户可配置,强制直连,settings.json 的 whitelist 键) ─────────
|
|
||||||
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用},命中即直连(不经过代理)。
|
def _use_pac_mode():
|
||||||
# 规则保存在内存缓存 _WHITELIST,连接建立时直接读取,不落盘回读;
|
"""是否默认启用 PAC 智能分流;缺失时保持原有默认值 True。"""
|
||||||
# _WHITELIST_VERSION 在保存时 +1,使按 (host, version) 键控的匹配缓存失效。
|
return bool(_settings_load().get("pac_mode", True))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_use_pac_mode(enabled):
|
||||||
|
"""保存 PAC 模式偏好,下一次启动时恢复。"""
|
||||||
|
data = _settings_load()
|
||||||
|
data["pac_mode"] = bool(enabled)
|
||||||
|
_settings_save(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _bypass_lan():
|
||||||
|
"""是否默认绕过内网 IP;缺失时保持原有默认值 True。"""
|
||||||
|
return bool(_settings_load().get("bypass_lan", True))
|
||||||
|
|
||||||
|
|
||||||
|
def _set_bypass_lan(enabled):
|
||||||
|
"""保存内网 IP 直连偏好,下一次启动时恢复。"""
|
||||||
|
data = _settings_load()
|
||||||
|
data["bypass_lan"] = bool(enabled)
|
||||||
|
_settings_save(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_display_mode():
|
||||||
|
"""主页面节点信息显示方式:domain 或 remark。"""
|
||||||
|
return "remark" if _settings_load().get("node_display") == "remark" else "domain"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_node_display_mode(mode):
|
||||||
|
"""保存主页面节点信息显示方式。"""
|
||||||
|
data = _settings_load()
|
||||||
|
data["node_display"] = "remark" if mode == "remark" else "domain"
|
||||||
|
_settings_save(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_behavior():
|
||||||
|
"""窗口关闭行为:exit(退出)或 tray(最小化到托盘)。"""
|
||||||
|
return "tray" if _settings_load().get("close_behavior") == "tray" else "exit"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_close_behavior(mode):
|
||||||
|
"""保存窗口关闭行为。"""
|
||||||
|
data = _settings_load()
|
||||||
|
data["close_behavior"] = "tray" if mode == "tray" else "exit"
|
||||||
|
_settings_save(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_user_config():
|
||||||
|
"""重置 settings.json;保留登录、节点缓存、第三方订阅和 PAC 数据。"""
|
||||||
|
global _WHITELIST, _ROUTE_RULES, _WHITELIST_VERSION, _NODES, _SELECTED_NODE
|
||||||
|
_settings_save({})
|
||||||
|
_WHITELIST = None
|
||||||
|
_ROUTE_RULES = None
|
||||||
|
_WHITELIST_VERSION += 1
|
||||||
|
_is_whitelisted.cache_clear()
|
||||||
|
_NODES = None
|
||||||
|
_SELECTED_NODE = 0
|
||||||
|
default_port = _port_val()
|
||||||
|
global _TLS_LOCAL_PORT, PROXY_PORT
|
||||||
|
_TLS_LOCAL_PORT = default_port
|
||||||
|
PROXY_PORT = default_port
|
||||||
|
log("已重置用户配置:本地偏好、域名路由、跳板和当前节点选择已恢复默认")
|
||||||
|
|
||||||
|
# ── 域名路由(兼容旧代理白名单,settings.json 的 whitelist 键) ────────
|
||||||
|
# 每条规则 {"value": 域名/IP/CIDR, "enabled": 是否启用,
|
||||||
|
# "action": "direct" | "node", "node_ref": 节点稳定标识}。
|
||||||
|
# action=direct 为强制不代理,action=node 为强制走指定节点;旧版只有 value/enabled
|
||||||
|
# 的白名单自动按 direct 解释。规则保存在内存缓存,修改后立即影响新连接。
|
||||||
|
|
||||||
_WHITELIST = None # 内存缓存:规范化后的规则列表
|
_WHITELIST = None # 内存缓存:规范化后的规则列表
|
||||||
_WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效)
|
_WHITELIST_VERSION = 0 # 规则版本号(保存时 +1,用于匹配缓存失效)
|
||||||
|
_ROUTE_RULES = None # 全量域名路由规则(含 direct/node)
|
||||||
|
|
||||||
def _whitelist_rules():
|
|
||||||
"""白名单规则列表(内存缓存,首次读取 settings.json;保存时同步更新)。"""
|
def _route_domain_normalize(entry: str) -> str:
|
||||||
global _WHITELIST
|
"""规范化域名规则,保留 ``*.`` 通配符,兼容旧白名单输入。"""
|
||||||
if _WHITELIST is None:
|
s = str(entry or "").strip().lower()
|
||||||
|
if "://" in s:
|
||||||
|
s = s.split("://", 1)[1]
|
||||||
|
s = s.split("/", 1)[0].rstrip(".")
|
||||||
|
wildcard = s.startswith("*.")
|
||||||
|
if wildcard:
|
||||||
|
s = s[2:]
|
||||||
|
return ("*." if wildcard else "") + s
|
||||||
|
|
||||||
|
|
||||||
|
def _route_rule_match(host: str, entry: str) -> bool:
|
||||||
|
"""匹配域名路由:普通域名兼容旧版的根域+子域语义,``*.`` 仅匹配子域。"""
|
||||||
|
value = _route_domain_normalize(entry)
|
||||||
|
h = str(host or "").rstrip(".").lower()
|
||||||
|
if value.startswith("*."):
|
||||||
|
base = value[2:]
|
||||||
|
return bool(base and h.endswith("." + base) and h != base)
|
||||||
|
return _whitelist_entry_match(h, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_node_reference(node):
|
||||||
|
"""固定节点规则使用稳定引用;跳板引用使用 profile ID,普通节点忽略备注变化。"""
|
||||||
|
if isinstance(node, dict) and node.get("_rrpp_source_kind") == "jump":
|
||||||
|
return _node_reference(node)
|
||||||
|
return _jump_node_reference(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_node_label(node):
|
||||||
|
if node.get("_rrpp_source_kind") == "jump":
|
||||||
|
return f"{node.get('_jump_name') or '自定义跳板'} ({_jump_route_text(node)})"
|
||||||
|
return f"{node.get('domain') or '???'} ({node.get('country') or '?'})"
|
||||||
|
|
||||||
|
|
||||||
|
def _route_rules():
|
||||||
|
"""读取并规范化全量路由规则;旧版 whitelist 自动迁移为强制直连。"""
|
||||||
|
global _ROUTE_RULES
|
||||||
|
if _ROUTE_RULES is None:
|
||||||
raw = _settings_load().get("whitelist")
|
raw = _settings_load().get("whitelist")
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raw = []
|
raw = []
|
||||||
_WHITELIST = [{"value": str(r.get("value") or "").strip().lower(),
|
result = []
|
||||||
"enabled": bool(r.get("enabled", True))}
|
for item in raw:
|
||||||
for r in raw if isinstance(r, dict) and str(r.get("value") or "").strip()]
|
if not isinstance(item, dict):
|
||||||
return _WHITELIST
|
continue
|
||||||
|
value = _route_domain_normalize(item.get("domain") or item.get("value") or "")
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
action = str(item.get("action") or ("node" if item.get("node_ref") else "direct")).lower()
|
||||||
|
if action not in ("direct", "node"):
|
||||||
|
action = "direct"
|
||||||
|
result.append({
|
||||||
|
"domain": value,
|
||||||
|
"enabled": bool(item.get("enabled", True)),
|
||||||
|
"action": action,
|
||||||
|
"node_ref": str(item.get("node_ref") or ""),
|
||||||
|
})
|
||||||
|
_ROUTE_RULES = result
|
||||||
|
return _ROUTE_RULES
|
||||||
|
|
||||||
def _whitelist_save(rules):
|
|
||||||
"""保存白名单规则(规范化 + 去空白条目),更新内存缓存并记录日志。"""
|
def _route_rule_for_host(host: str):
|
||||||
global _WHITELIST, _WHITELIST_VERSION
|
"""返回目标域名命中的最高优先级规则;规则变更对新连接立即生效。"""
|
||||||
clean = [{"value": _whitelist_normalize(r.get("value") or ""),
|
matches = []
|
||||||
"enabled": bool(r.get("enabled", True))}
|
for rule in _route_rules():
|
||||||
for r in rules if _whitelist_normalize(r.get("value") or "")]
|
if not rule.get("enabled") or not _route_rule_match(host, rule.get("domain", "")):
|
||||||
|
continue
|
||||||
|
domain = rule["domain"]
|
||||||
|
base = domain[2:] if domain.startswith("*.") else domain
|
||||||
|
# 更长域名优先;同一根域下显式通配符优先于兼容旧语义的普通域名。
|
||||||
|
matches.append((len(base), int(domain.startswith("*.")), rule))
|
||||||
|
return max(matches, key=lambda item: (item[0], item[1]))[2] if matches else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_route_nodes():
|
||||||
|
"""返回可被域名规则选择的当前普通节点与多跳虚拟节点。"""
|
||||||
|
candidates = [node for node in _load_nodes() if _node_reachable(node)]
|
||||||
|
counts = {}
|
||||||
|
for node in candidates:
|
||||||
|
ref = _route_node_reference(node)
|
||||||
|
counts[ref] = counts.get(ref, 0) + 1
|
||||||
|
return [node for node in candidates if counts[_route_node_reference(node)] == 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_route_node(rule):
|
||||||
|
"""按规则解析固定节点;节点下线或引用失效时返回 None。"""
|
||||||
|
ref = str((rule or {}).get("node_ref") or "")
|
||||||
|
if not ref:
|
||||||
|
return None
|
||||||
|
candidates = [node for node in _load_nodes() if _route_node_reference(node) == ref]
|
||||||
|
return candidates[0] if len(candidates) == 1 and _node_reachable(candidates[0]) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_route_rules():
|
||||||
|
"""供设置界面展示规则及节点可用状态,不暴露节点认证信息。"""
|
||||||
|
nodes = {_route_node_reference(node): node for node in _fixed_route_nodes()}
|
||||||
|
result = []
|
||||||
|
for rule in _route_rules():
|
||||||
|
item = dict(rule)
|
||||||
|
node = nodes.get(rule.get("node_ref")) if rule.get("action") == "node" else None
|
||||||
|
item["node_label"] = _route_node_label(node) if node else ("节点不可用" if rule.get("action") == "node" else "强制不代理")
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_route_save(rules):
|
||||||
|
"""保存域名路由规则;保留旧 whitelist 键名以兼容已有用户设置。"""
|
||||||
|
global _ROUTE_RULES, _WHITELIST, _WHITELIST_VERSION
|
||||||
|
clean = []
|
||||||
|
for item in rules or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
value = _route_domain_normalize(item.get("domain") or item.get("value") or "")
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
action = str(item.get("action") or "direct").lower()
|
||||||
|
if action not in ("direct", "node"):
|
||||||
|
action = "direct"
|
||||||
|
clean.append({
|
||||||
|
"value": value,
|
||||||
|
"enabled": bool(item.get("enabled", True)),
|
||||||
|
"action": action,
|
||||||
|
"node_ref": str(item.get("node_ref") or "") if action == "node" else "",
|
||||||
|
})
|
||||||
data = _settings_load()
|
data = _settings_load()
|
||||||
data["whitelist"] = clean
|
data["whitelist"] = clean
|
||||||
_settings_save(data)
|
_settings_save(data)
|
||||||
_WHITELIST = clean
|
_ROUTE_RULES = None
|
||||||
|
_WHITELIST = None
|
||||||
_WHITELIST_VERSION += 1
|
_WHITELIST_VERSION += 1
|
||||||
log(f"代理白名单已保存: {len(clean)} 条(立即生效)")
|
_is_whitelisted.cache_clear()
|
||||||
|
log(f"域名路由已保存: {len(clean)} 条(立即生效)")
|
||||||
|
|
||||||
|
def _whitelist_rules():
|
||||||
|
"""兼容旧调用方:只返回强制不代理规则。"""
|
||||||
|
global _WHITELIST
|
||||||
|
if _WHITELIST is None:
|
||||||
|
_WHITELIST = [{"value": r["domain"], "enabled": r["enabled"]}
|
||||||
|
for r in _route_rules() if r.get("action") == "direct"]
|
||||||
|
return _WHITELIST
|
||||||
|
|
||||||
|
def _whitelist_save(rules):
|
||||||
|
"""旧白名单保存入口:把传入规则按强制不代理处理。"""
|
||||||
|
_fixed_route_save([{"domain": r.get("value"), "enabled": r.get("enabled", True), "action": "direct"}
|
||||||
|
for r in rules or []])
|
||||||
|
|
||||||
def _whitelist_normalize(entry: str) -> str:
|
def _whitelist_normalize(entry: str) -> str:
|
||||||
"""条目规范化:去协议头/通配符/末尾点,统一小写。"""
|
"""条目规范化:去协议头/通配符/末尾点,统一小写。"""
|
||||||
@@ -1407,6 +1669,23 @@ def _whitelist_validate_entry(entry: str):
|
|||||||
return "", "IP 格式不正确"
|
return "", "IP 格式不正确"
|
||||||
return s, ""
|
return s, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_route_validate_domain(entry: str):
|
||||||
|
"""校验域名路由规则;支持 ``example.com`` 与 ``*.example.com``。"""
|
||||||
|
value = _route_domain_normalize(entry)
|
||||||
|
base = value[2:] if value.startswith("*.") else value
|
||||||
|
if not base:
|
||||||
|
return "", "域名不能为空"
|
||||||
|
if any(ch.isspace() for ch in base) or "/" in base or ":" in base:
|
||||||
|
return "", "域名格式不正确"
|
||||||
|
labels = base.split(".")
|
||||||
|
if len(labels) < 2 or any(not label or label.startswith("-") or label.endswith("-")
|
||||||
|
for label in labels):
|
||||||
|
return "", "请输入完整域名,例如 example.com"
|
||||||
|
if any(not all(ch.isalnum() or ch == "-" for ch in label) for label in labels):
|
||||||
|
return "", "域名只能包含字母、数字、短横线和点"
|
||||||
|
return value, ""
|
||||||
|
|
||||||
def _ver_tuple(v):
|
def _ver_tuple(v):
|
||||||
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
|
"""版本号转数字元组:'v0.1.0' -> (0, 1, 0),无法解析的段按 0 处理。"""
|
||||||
parts = []
|
parts = []
|
||||||
@@ -2021,13 +2300,38 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
jump_chain=None):
|
jump_chain=None):
|
||||||
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
|
"""处理一条客户端连接:内网(bypass_lan)/中国IP(智能分流)目标直接连接;其余走远端隧道。"""
|
||||||
import socket, select
|
import socket, select
|
||||||
|
connection_id = _next_connection_id()
|
||||||
|
started_at = time.monotonic()
|
||||||
|
try:
|
||||||
|
peer = client.getpeername()
|
||||||
|
peer_text = f"{peer[0]}:{peer[1]}" if isinstance(peer, tuple) and len(peer) >= 2 else str(peer)
|
||||||
|
except Exception:
|
||||||
|
peer_text = "未知"
|
||||||
|
|
||||||
|
def _chain_detail(chain):
|
||||||
|
return "→".join(
|
||||||
|
f"{node.get('domain') or '?'}:{_node_http_port(node)}"
|
||||||
|
for node in (chain or [])
|
||||||
|
)
|
||||||
|
|
||||||
client.settimeout(2.0)
|
client.settimeout(2.0)
|
||||||
buf = b""
|
buf = b""
|
||||||
is_http = None
|
is_http = None
|
||||||
is_connect = False
|
is_connect = False
|
||||||
|
first_line = ""
|
||||||
|
target = None
|
||||||
|
request_type = "未知请求"
|
||||||
|
target_text = "未解析"
|
||||||
|
rule_match = "否"
|
||||||
|
route_summary = f"上游节点 {_chain_detail(jump_chain) or f'{remote_host}:{remote_port}'}"
|
||||||
injector = None
|
injector = None
|
||||||
direct_target = None
|
direct_target = None
|
||||||
allow_direct_fallback = False
|
allow_direct_fallback = False
|
||||||
|
forced_rule = None
|
||||||
|
forced_node = None
|
||||||
|
route_remote_host = remote_host
|
||||||
|
route_remote_port = remote_port
|
||||||
|
route_jump_chain = jump_chain
|
||||||
max_headers = 65536
|
max_headers = 65536
|
||||||
try:
|
try:
|
||||||
while len(buf) < max_headers:
|
while len(buf) < max_headers:
|
||||||
@@ -2050,26 +2354,73 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
if is_http and b"\r\n\r\n" in buf:
|
if is_http and b"\r\n\r\n" in buf:
|
||||||
is_connect = buf.startswith(b"CONNECT ")
|
is_connect = buf.startswith(b"CONNECT ")
|
||||||
first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace")
|
first_line = buf.split(b"\r\n", 1)[0].decode("latin-1", "replace")
|
||||||
|
request_type = "CONNECT" if is_connect else (first_line.split(" ", 1)[0] or "HTTP")
|
||||||
if is_connect:
|
if is_connect:
|
||||||
target = _parse_connect_authority(first_line)
|
target = _parse_connect_authority(first_line)
|
||||||
else:
|
else:
|
||||||
target = _parse_http_absolute(first_line)
|
target = _parse_http_absolute(first_line)
|
||||||
if target:
|
if target:
|
||||||
direct_target = _direct_connect_target(
|
target_text = f"{target[0]}:{target[1]}"
|
||||||
target[0], target[1], bypass_lan, smart)
|
log_detail(f"[连接 #{connection_id}] 收到请求: {request_type} {target_text},客户端={peer_text}")
|
||||||
allow_direct_fallback = (direct_target is not None and
|
forced_rule = _route_rule_for_host(target[0])
|
||||||
_is_smart_china_direct(target[0], bypass_lan, smart))
|
if forced_rule and forced_rule.get("action") == "direct":
|
||||||
|
rule_match = f"是({forced_rule.get('domain') or target[0]},强制直连)"
|
||||||
|
log_detail(f"[连接 #{connection_id}] 规则匹配: {rule_match}")
|
||||||
|
# 显式强制不代理优先于 PAC 的域名表和 IP 判定。
|
||||||
|
direct_target = (target[0], target[1])
|
||||||
|
route_summary = f"强制直连 {target_text}"
|
||||||
|
elif forced_rule and forced_rule.get("action") == "node":
|
||||||
|
rule_match = f"是({forced_rule.get('domain') or target[0]},强制节点)"
|
||||||
|
log_detail(f"[连接 #{connection_id}] 规则匹配: {rule_match}")
|
||||||
|
forced_node = _fixed_route_node(forced_rule)
|
||||||
|
if forced_node is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"域名 {target[0]} 指定的固定节点不可用,请在设置中更新域名路由"
|
||||||
|
)
|
||||||
|
terminal = _terminal_node(forced_node)
|
||||||
|
route_remote_host = str(terminal.get("domain") or "")
|
||||||
|
route_remote_port = _node_http_port(terminal)
|
||||||
|
route_jump_chain = _jump_chain(forced_node)
|
||||||
|
# 固定节点规则必须走指定节点,不允许 PAC 把目标改为直连。
|
||||||
|
direct_target = None
|
||||||
|
route_summary = (
|
||||||
|
f"强制多跳 {_chain_detail(route_jump_chain)}"
|
||||||
|
if route_jump_chain else
|
||||||
|
f"强制节点 {route_remote_host}:{route_remote_port}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log_detail(f"[连接 #{connection_id}] 规则匹配: 否(未命中自定义域名路由)")
|
||||||
|
direct_target = _direct_connect_target(
|
||||||
|
target[0], target[1], bypass_lan, smart)
|
||||||
|
allow_direct_fallback = (direct_target is not None and
|
||||||
|
_is_smart_china_direct(target[0], bypass_lan, smart))
|
||||||
|
if direct_target:
|
||||||
|
policy = "PAC/智能分流直连" if allow_direct_fallback else "直连策略"
|
||||||
|
route_summary = f"{policy} {direct_target[0]}:{direct_target[1]}"
|
||||||
|
else:
|
||||||
|
route_summary = (
|
||||||
|
f"多跳上游 {_chain_detail(route_jump_chain)}"
|
||||||
|
if route_jump_chain else
|
||||||
|
f"上游节点 {route_remote_host}:{route_remote_port}"
|
||||||
|
)
|
||||||
|
log_detail(f"[连接 #{connection_id}] 路由决策: {route_summary},TLS={'是' if use_tls else '否'}")
|
||||||
|
else:
|
||||||
|
log_detail(f"[连接 #{connection_id}] 无法解析目标: {first_line or '空请求'},客户端={peer_text}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"[转发器] 读取首包失败: {e}")
|
log(f"[转发器] 读取首包失败: {e}")
|
||||||
|
log_detail(f"[连接 #{connection_id}] 失败: 读取首包,目标={target_text},规则匹配={rule_match},错误={e}")
|
||||||
client.close()
|
client.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
remote = None
|
remote = None
|
||||||
|
up_bytes = 0
|
||||||
|
down_bytes = 0
|
||||||
try:
|
try:
|
||||||
remote, via_tunnel = _connect_route(
|
remote, via_tunnel = _connect_route(
|
||||||
direct_target, allow_direct_fallback, remote_host, remote_port, ctx, use_tls,
|
direct_target, allow_direct_fallback, route_remote_host, route_remote_port, ctx, use_tls,
|
||||||
jump_chain=jump_chain)
|
jump_chain=route_jump_chain)
|
||||||
count_traffic = via_tunnel
|
count_traffic = via_tunnel
|
||||||
|
log_detail(f"[连接 #{connection_id}] 连接成功: 目标={target_text},路径={route_summary},承载={'隧道' if via_tunnel else '直连'}")
|
||||||
if not via_tunnel:
|
if not via_tunnel:
|
||||||
if is_connect:
|
if is_connect:
|
||||||
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||||
@@ -2079,14 +2430,18 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
else:
|
else:
|
||||||
if is_http and b"\r\n\r\n" in buf:
|
if is_http and b"\r\n\r\n" in buf:
|
||||||
if not is_connect:
|
if not is_connect:
|
||||||
injector = _make_request_injector()
|
injector = _make_request_injector(_terminal_node(forced_node) if forced_node else None)
|
||||||
buf = _inject_proxy_auth(buf)
|
buf = _inject_proxy_auth(
|
||||||
|
buf, _terminal_node(forced_node) if forced_node else None
|
||||||
|
)
|
||||||
if buf:
|
if buf:
|
||||||
remote.sendall(buf)
|
remote.sendall(buf)
|
||||||
|
up_bytes += len(buf)
|
||||||
if via_tunnel:
|
if via_tunnel:
|
||||||
_traffic_add(up=len(buf))
|
_traffic_add(up=len(buf))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log(f"[转发器] 连接目标失败: {e}")
|
log(f"[转发器] 连接目标失败: {e}")
|
||||||
|
log_detail(f"[连接 #{connection_id}] 连接失败: 目标={target_text},规则匹配={rule_match},路径={route_summary},错误={e}")
|
||||||
try:
|
try:
|
||||||
client.close()
|
client.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2103,6 +2458,7 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
# CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时;
|
# CONNECT 隧道承载 WebSocket / 推送等长连接,空闲属正常,不设超时;
|
||||||
# 明文 HTTP 连接空闲 120 秒回收
|
# 明文 HTTP 连接空闲 120 秒回收
|
||||||
idle_timeout = None if is_connect else 120
|
idle_timeout = None if is_connect else 120
|
||||||
|
close_reason = "对端关闭"
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if isinstance(remote, _LayeredTlsTransport):
|
if isinstance(remote, _LayeredTlsTransport):
|
||||||
@@ -2111,33 +2467,40 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
if data is None:
|
if data is None:
|
||||||
break
|
break
|
||||||
if not data:
|
if not data:
|
||||||
|
close_reason = "上游关闭"
|
||||||
return
|
return
|
||||||
client.sendall(data)
|
client.sendall(data)
|
||||||
|
down_bytes += len(data)
|
||||||
if count_traffic:
|
if count_traffic:
|
||||||
_traffic_add(down=len(data))
|
_traffic_add(down=len(data))
|
||||||
remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote
|
remote_wait = remote.wait_socket() if hasattr(remote, "wait_socket") else remote
|
||||||
r, _, _ = select.select([client, remote_wait], [], [], idle_timeout)
|
r, _, _ = select.select([client, remote_wait], [], [], idle_timeout)
|
||||||
if not r:
|
if not r:
|
||||||
|
close_reason = f"空闲超时({idle_timeout} 秒)"
|
||||||
break
|
break
|
||||||
if client in r:
|
if client in r:
|
||||||
data = client.recv(65536)
|
data = client.recv(65536)
|
||||||
if not data:
|
if not data:
|
||||||
|
close_reason = "客户端关闭"
|
||||||
break
|
break
|
||||||
if injector is not None:
|
if injector is not None:
|
||||||
data = injector(data)
|
data = injector(data)
|
||||||
if data:
|
if data:
|
||||||
remote.sendall(data)
|
remote.sendall(data)
|
||||||
|
up_bytes += len(data)
|
||||||
if count_traffic:
|
if count_traffic:
|
||||||
_traffic_add(up=len(data))
|
_traffic_add(up=len(data))
|
||||||
if remote_wait in r:
|
if remote_wait in r:
|
||||||
data = remote.recv(65536)
|
data = remote.recv(65536)
|
||||||
if not data:
|
if not data:
|
||||||
|
close_reason = "上游关闭"
|
||||||
break
|
break
|
||||||
client.sendall(data)
|
client.sendall(data)
|
||||||
|
down_bytes += len(data)
|
||||||
if count_traffic:
|
if count_traffic:
|
||||||
_traffic_add(down=len(data))
|
_traffic_add(down=len(data))
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
close_reason = f"转发异常: {e}"
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
client.close()
|
client.close()
|
||||||
@@ -2147,6 +2510,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
remote.close()
|
remote.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
elapsed = time.monotonic() - started_at
|
||||||
|
log_detail(
|
||||||
|
f"[连接 #{connection_id}] 已关闭: 目标={target_text},规则匹配={rule_match},"
|
||||||
|
f"原因={close_reason},上行={_fmt_bytes(up_bytes)},下行={_fmt_bytes(down_bytes)},"
|
||||||
|
f"持续={elapsed:.3f} 秒"
|
||||||
|
)
|
||||||
def _tls_stop():
|
def _tls_stop():
|
||||||
"""停止 TLS 转发器监听线程。"""
|
"""停止 TLS 转发器监听线程。"""
|
||||||
global _tls_forwarder
|
global _tls_forwarder
|
||||||
@@ -2239,9 +2608,28 @@ def _login_services():
|
|||||||
def _whitelist_services():
|
def _whitelist_services():
|
||||||
return WhitelistServices(
|
return WhitelistServices(
|
||||||
load_icon=load_icon,
|
load_icon=load_icon,
|
||||||
load_rules=_whitelist_rules,
|
load_rules=lambda: [
|
||||||
validate_rule=_whitelist_validate_entry,
|
{"value": item["domain"], "enabled": item["enabled"]}
|
||||||
save_rules=_whitelist_save,
|
for item in _fixed_route_rules() if item.get("action") == "direct"
|
||||||
|
],
|
||||||
|
validate_rule=_fixed_route_validate_domain,
|
||||||
|
save_rules=lambda rules: _fixed_route_save([
|
||||||
|
{"domain": item.get("value"), "enabled": item.get("enabled", True), "action": "direct"}
|
||||||
|
for item in rules
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed_route_services():
|
||||||
|
return FixedRouteServices(
|
||||||
|
load_icon=load_icon,
|
||||||
|
load_rules=_fixed_route_rules,
|
||||||
|
available_nodes=lambda: [
|
||||||
|
{"ref": _route_node_reference(node), "label": _route_node_label(node)}
|
||||||
|
for node in _fixed_route_nodes()
|
||||||
|
],
|
||||||
|
validate_domain=_fixed_route_validate_domain,
|
||||||
|
save_rules=_fixed_route_save,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2264,6 +2652,10 @@ def _open_login_dialog(parent=None):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _log_services():
|
||||||
|
return LogServices(load_icon=load_icon, get_logs=_log_history)
|
||||||
|
|
||||||
|
|
||||||
def _logout_user_data():
|
def _logout_user_data():
|
||||||
"""清除官方登录与节点缓存;第三方订阅保持不变。"""
|
"""清除官方登录与节点缓存;第三方订阅保持不变。"""
|
||||||
global _NODES
|
global _NODES
|
||||||
@@ -2281,15 +2673,23 @@ def _settings_services():
|
|||||||
load_icon=load_icon,
|
load_icon=load_icon,
|
||||||
version=VERSION,
|
version=VERSION,
|
||||||
get_port=lambda: _TLS_LOCAL_PORT,
|
get_port=lambda: _TLS_LOCAL_PORT,
|
||||||
|
get_builtin_pac=_use_builtin_pac,
|
||||||
get_update_via_proxy=_update_via_proxy,
|
get_update_via_proxy=_update_via_proxy,
|
||||||
get_skip_update_check=_skip_update_check,
|
get_skip_update_check=_skip_update_check,
|
||||||
|
get_node_display_mode=_node_display_mode,
|
||||||
|
get_close_behavior=_close_behavior,
|
||||||
parse_port=_parse_port,
|
parse_port=_parse_port,
|
||||||
set_port=_set_local_port,
|
set_port=_set_local_port,
|
||||||
|
set_builtin_pac=_set_builtin_pac,
|
||||||
set_update_via_proxy=_set_update_via_proxy,
|
set_update_via_proxy=_set_update_via_proxy,
|
||||||
set_skip_update_check=_set_skip_update_check,
|
set_skip_update_check=_set_skip_update_check,
|
||||||
open_whitelist=lambda parent: WhitelistDialog(_whitelist_services(), parent).exec_(),
|
set_node_display_mode=_set_node_display_mode,
|
||||||
|
set_close_behavior=_set_close_behavior,
|
||||||
|
reset_user_config=_reset_user_config,
|
||||||
|
open_whitelist=lambda parent: FixedRouteDialog(_fixed_route_services(), parent).exec_(),
|
||||||
open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(),
|
open_jump=lambda parent: JumpDialog(_jump_services(), parent).exec_(),
|
||||||
open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(),
|
open_third_party=lambda parent: ThirdPartyDialog(_third_party_services(), parent).exec_(),
|
||||||
|
open_logs=lambda parent: LogDialog(_log_services(), parent).exec_(),
|
||||||
has_login=lambda: _auth_load() is not None,
|
has_login=lambda: _auth_load() is not None,
|
||||||
open_login=_open_login_dialog,
|
open_login=_open_login_dialog,
|
||||||
logout=_logout_user_data,
|
logout=_logout_user_data,
|
||||||
@@ -2322,6 +2722,9 @@ class MainWindow(QWidget):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
|
self._proxy_set_by_us = False # 系统代理是否由本程序开启(退出时只清理自己设置的)
|
||||||
|
self._quitting = False
|
||||||
|
self._tray = None
|
||||||
|
self._tray_menu = None
|
||||||
# 启动完成前仍允许用户取消。事件由 GUI 线程置位,后台启动线程只读取;
|
# 启动完成前仍允许用户取消。事件由 GUI 线程置位,后台启动线程只读取;
|
||||||
# 锁保护系统代理写入和取消清理之间的交接,避免遗留代理。
|
# 锁保护系统代理写入和取消清理之间的交接,避免遗留代理。
|
||||||
self._start_cancel_event = None
|
self._start_cancel_event = None
|
||||||
@@ -2343,6 +2746,7 @@ class MainWindow(QWidget):
|
|||||||
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
|
_user_init_local() # 首启初始化用户数据目录(节点模板),须在 init_ui 读节点之前
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
|
self._setup_tray()
|
||||||
|
|
||||||
# 每 2 秒刷新一次状态指示灯
|
# 每 2 秒刷新一次状态指示灯
|
||||||
self.timer = QTimer()
|
self.timer = QTimer()
|
||||||
@@ -2362,6 +2766,65 @@ class MainWindow(QWidget):
|
|||||||
# 节点未配置时延迟弹出登录框(等窗口显示后)
|
# 节点未配置时延迟弹出登录框(等窗口显示后)
|
||||||
QTimer.singleShot(600, self._maybe_show_login)
|
QTimer.singleShot(600, self._maybe_show_login)
|
||||||
|
|
||||||
|
def _setup_tray(self):
|
||||||
|
"""创建托盘图标和动态状态菜单;系统不支持托盘时保留窗口模式。"""
|
||||||
|
if not QSystemTrayIcon.isSystemTrayAvailable():
|
||||||
|
log("当前系统不支持托盘图标,关闭窗口时只能退出程序")
|
||||||
|
return
|
||||||
|
self._tray = QSystemTrayIcon(load_icon(), self)
|
||||||
|
self._tray.setToolTip("Royirin Rosmontis Private Proxy")
|
||||||
|
self._tray_menu = QMenu(self)
|
||||||
|
self._tray_menu.aboutToShow.connect(self._update_tray_menu)
|
||||||
|
self._tray.setContextMenu(self._tray_menu)
|
||||||
|
self._tray.activated.connect(self._on_tray_activated)
|
||||||
|
self._tray.show()
|
||||||
|
self._update_tray_menu()
|
||||||
|
|
||||||
|
def _on_tray_activated(self, reason):
|
||||||
|
"""双击托盘图标显示主页面。"""
|
||||||
|
if reason == QSystemTrayIcon.DoubleClick:
|
||||||
|
self._show_main_window()
|
||||||
|
|
||||||
|
def _show_main_window(self):
|
||||||
|
"""从托盘恢复主页面并置于前台。"""
|
||||||
|
self.show()
|
||||||
|
self.showNormal()
|
||||||
|
self.raise_()
|
||||||
|
self.activateWindow()
|
||||||
|
|
||||||
|
def _update_tray_menu(self):
|
||||||
|
"""刷新托盘菜单中的三行状态信息。"""
|
||||||
|
if self._tray_menu is None:
|
||||||
|
return
|
||||||
|
self._tray_menu.clear()
|
||||||
|
|
||||||
|
show_action = QAction("显示主页面", self._tray_menu)
|
||||||
|
show_action.triggered.connect(self._show_main_window)
|
||||||
|
self._tray_menu.addAction(show_action)
|
||||||
|
self._tray_menu.addSeparator()
|
||||||
|
|
||||||
|
proxy_on = is_proxy_on()
|
||||||
|
pac_on = _should_use_internal_pac(_use_pac_mode(), _use_builtin_pac())
|
||||||
|
bypass_lan = _bypass_lan()
|
||||||
|
for text in (
|
||||||
|
f"代理:{'开' if proxy_on else '关'}",
|
||||||
|
f"PAC:{'启用' if pac_on else '关闭'}",
|
||||||
|
f"内网IP:{'不代理' if bypass_lan else '代理'}",
|
||||||
|
):
|
||||||
|
status_action = QAction(text, self._tray_menu)
|
||||||
|
status_action.setEnabled(False)
|
||||||
|
self._tray_menu.addAction(status_action)
|
||||||
|
|
||||||
|
self._tray_menu.addSeparator()
|
||||||
|
exit_action = QAction("退出", self._tray_menu)
|
||||||
|
exit_action.triggered.connect(self._exit_from_tray)
|
||||||
|
self._tray_menu.addAction(exit_action)
|
||||||
|
|
||||||
|
def _exit_from_tray(self):
|
||||||
|
"""托盘菜单中的显式退出。"""
|
||||||
|
self._quitting = True
|
||||||
|
self.close()
|
||||||
|
|
||||||
def _verify_node_thread(self):
|
def _verify_node_thread(self):
|
||||||
"""等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。
|
"""等节点配置在线同步完成后,校验当前选中节点是否可用,必要时自动回退。
|
||||||
|
|
||||||
@@ -2423,13 +2886,15 @@ class MainWindow(QWidget):
|
|||||||
pac_layout.setSpacing(8)
|
pac_layout.setSpacing(8)
|
||||||
pac_layout.addStretch()
|
pac_layout.addStretch()
|
||||||
self.cb_bypass_lan = QCheckBox("不代理内网 IP")
|
self.cb_bypass_lan = QCheckBox("不代理内网 IP")
|
||||||
self.cb_bypass_lan.setChecked(True) # 默认勾选:内网 IP 直连,不经过本地转发器
|
self.cb_bypass_lan.setChecked(_bypass_lan())
|
||||||
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
self.cb_bypass_lan.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
self.cb_bypass_lan.toggled.connect(_set_bypass_lan)
|
||||||
pac_layout.addWidget(self.cb_bypass_lan)
|
pac_layout.addWidget(self.cb_bypass_lan)
|
||||||
self.cb_pac = QCheckBox("PAC 规则模式")
|
self.cb_pac = QCheckBox("PAC 规则模式")
|
||||||
self.cb_pac.setChecked(True) # 默认开启智能分流(域名表/中国IP判定已足够完善)
|
self.cb_pac.setChecked(_use_pac_mode())
|
||||||
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
self.cb_pac.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
|
self.cb_pac.setToolTip("只代理海外服务,使用域名和解析 IP 匹配。")
|
||||||
|
self.cb_pac.toggled.connect(_set_use_pac_mode)
|
||||||
pac_layout.addWidget(self.cb_pac)
|
pac_layout.addWidget(self.cb_pac)
|
||||||
pac_layout.addStretch()
|
pac_layout.addStretch()
|
||||||
layout.addLayout(pac_layout)
|
layout.addLayout(pac_layout)
|
||||||
@@ -2550,7 +3015,11 @@ class MainWindow(QWidget):
|
|||||||
layout.addWidget(hints)
|
layout.addWidget(hints)
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
"""关闭窗口时自动清理代理和隧道。"""
|
"""按用户设置退出或隐藏到托盘。"""
|
||||||
|
if not self._quitting and _close_behavior() == "tray" and self._tray is not None:
|
||||||
|
event.ignore()
|
||||||
|
self.hide()
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
self.quit_app()
|
self.quit_app()
|
||||||
finally:
|
finally:
|
||||||
@@ -2620,6 +3089,7 @@ class MainWindow(QWidget):
|
|||||||
# 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全)
|
# 在 GUI 线程读取控件状态传参,避免子线程直接访问 Qt 控件(非线程安全)
|
||||||
use_pac = self.cb_pac.isChecked()
|
use_pac = self.cb_pac.isChecked()
|
||||||
use_bypass_lan = self.cb_bypass_lan.isChecked()
|
use_bypass_lan = self.cb_bypass_lan.isChecked()
|
||||||
|
use_builtin_pac = _use_builtin_pac()
|
||||||
self._start_cancel_event = threading.Event()
|
self._start_cancel_event = threading.Event()
|
||||||
self._start_in_progress = True
|
self._start_in_progress = True
|
||||||
# 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。
|
# 网络探测可能需要数秒;此时保留按钮给用户取消,而不是把界面锁死。
|
||||||
@@ -2627,12 +3097,12 @@ class MainWindow(QWidget):
|
|||||||
self.btn_toggle.setEnabled(True)
|
self.btn_toggle.setEnabled(True)
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=self._start_thread,
|
target=self._start_thread,
|
||||||
args=(use_pac, use_bypass_lan, self._start_cancel_event),
|
args=(use_pac, use_bypass_lan, use_builtin_pac, self._start_cancel_event),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
def _start_thread(self, use_pac, use_bypass_lan, cancel_event):
|
def _start_thread(self, use_pac, use_bypass_lan, use_builtin_pac, cancel_event):
|
||||||
try:
|
try:
|
||||||
self._start_proxy(use_pac, use_bypass_lan, cancel_event)
|
self._start_proxy(use_pac, use_bypass_lan, use_builtin_pac, cancel_event)
|
||||||
_raise_if_start_cancelled(cancel_event)
|
_raise_if_start_cancelled(cancel_event)
|
||||||
self._op_done.emit("start", True)
|
self._op_done.emit("start", True)
|
||||||
except _StartCancelled:
|
except _StartCancelled:
|
||||||
@@ -2657,8 +3127,8 @@ class MainWindow(QWidget):
|
|||||||
_tls_stop()
|
_tls_stop()
|
||||||
_stop_pac_server()
|
_stop_pac_server()
|
||||||
|
|
||||||
def _start_proxy(self, use_pac, use_bypass_lan, cancel_event=None):
|
def _start_proxy(self, use_pac, use_bypass_lan, use_builtin_pac=False, cancel_event=None):
|
||||||
"""启动本地转发器并设置系统代理(PAC/固定两种模式),可在后台线程调用。"""
|
"""启动本地转发器并设置系统代理(普通 PAC、内置 PAC 或固定代理)。"""
|
||||||
if _endpoint_domain() == "???":
|
if _endpoint_domain() == "???":
|
||||||
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
# 未配置节点时禁止开启:否则系统代理会指向连不通任何目标的转发器,导致断网
|
||||||
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
raise RuntimeError("未配置节点:请先通过 nodes_url 在线更新或 nodes_tool 导入节点配置")
|
||||||
@@ -2674,16 +3144,18 @@ class MainWindow(QWidget):
|
|||||||
if jump_chain:
|
if jump_chain:
|
||||||
log(f" 跳板路径: {_jump_route_text(current_node)}")
|
log(f" 跳板路径: {_jump_route_text(current_node)}")
|
||||||
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
|
# PAC 模式:全部流量进转发器,按域名表/解析 IP 智能分流
|
||||||
if use_pac:
|
use_internal_pac = _should_use_internal_pac(use_pac, use_builtin_pac)
|
||||||
|
if use_internal_pac:
|
||||||
_load_cn_ranges()
|
_load_cn_ranges()
|
||||||
_load_force_proxy_suffixes()
|
_load_force_proxy_suffixes()
|
||||||
_load_direct_domains()
|
_load_direct_domains()
|
||||||
_raise_if_start_cancelled(cancel_event)
|
_raise_if_start_cancelled(cancel_event)
|
||||||
_tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan, smart=use_pac,
|
_tls_start(ep, _endpoint_port(), use_tls=True, bypass_lan=use_bypass_lan,
|
||||||
|
smart=use_internal_pac,
|
||||||
jump_chain=jump_chain)
|
jump_chain=jump_chain)
|
||||||
try:
|
try:
|
||||||
_raise_if_start_cancelled(cancel_event)
|
_raise_if_start_cancelled(cancel_event)
|
||||||
if use_pac:
|
if _should_use_system_pac(use_pac, use_builtin_pac):
|
||||||
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
|
# 本地生成全量转发 PAC(不再从服务器拉取),全部流量交给转发器做智能分流
|
||||||
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
|
pac_content = _build_pac(f"{PROXY_HOST}:{PROXY_PORT}")
|
||||||
if use_bypass_lan:
|
if use_bypass_lan:
|
||||||
@@ -2701,6 +3173,8 @@ class MainWindow(QWidget):
|
|||||||
_raise_if_start_cancelled(cancel_event)
|
_raise_if_start_cancelled(cancel_event)
|
||||||
proxy_on(bypass_lan=use_bypass_lan)
|
proxy_on(bypass_lan=use_bypass_lan)
|
||||||
self._proxy_set_by_us = True
|
self._proxy_set_by_us = True
|
||||||
|
if use_builtin_pac:
|
||||||
|
log(f"使用内置 PAC:系统固定代理 127.0.0.1:{PROXY_PORT},转发器内部智能分流")
|
||||||
_raise_if_start_cancelled(cancel_event)
|
_raise_if_start_cancelled(cancel_event)
|
||||||
if use_bypass_lan:
|
if use_bypass_lan:
|
||||||
log("不代理内网 IP:内网地址将直连")
|
log("不代理内网 IP:内网地址将直连")
|
||||||
@@ -2755,8 +3229,15 @@ class MainWindow(QWidget):
|
|||||||
def _show_settings_dialog(self):
|
def _show_settings_dialog(self):
|
||||||
"""打开设置弹窗;第三方订阅与登录状态可独立管理。"""
|
"""打开设置弹窗;第三方订阅与登录状态可独立管理。"""
|
||||||
SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_()
|
SettingsDialog(_settings_services(), self, port_locked=self._proxy_set_by_us).exec_()
|
||||||
|
self._refresh_preferences()
|
||||||
self._refresh_subtitle()
|
self._refresh_subtitle()
|
||||||
|
|
||||||
|
def _refresh_preferences(self):
|
||||||
|
"""设置页关闭后同步主页面的本地偏好显示。"""
|
||||||
|
self.cb_pac.setChecked(_use_pac_mode())
|
||||||
|
self.cb_bypass_lan.setChecked(_bypass_lan())
|
||||||
|
self._update_tray_menu()
|
||||||
|
|
||||||
# ── 节点登录 ──
|
# ── 节点登录 ──
|
||||||
def _maybe_show_login(self):
|
def _maybe_show_login(self):
|
||||||
"""首次无节点时提示登录;用户可跳过并在主界面添加第三方订阅。"""
|
"""首次无节点时提示登录;用户可跳过并在主界面添加第三方订阅。"""
|
||||||
@@ -2847,6 +3328,7 @@ class MainWindow(QWidget):
|
|||||||
def _refresh_subtitle(self):
|
def _refresh_subtitle(self):
|
||||||
"""刷新节点副标题文字。"""
|
"""刷新节点副标题文字。"""
|
||||||
self.subtitle.setText(_endpoint_label())
|
self.subtitle.setText(_endpoint_label())
|
||||||
|
self._update_tray_menu()
|
||||||
|
|
||||||
def _update_node_label(self):
|
def _update_node_label(self):
|
||||||
"""代理运行中时提示禁止切换节点。"""
|
"""代理运行中时提示禁止切换节点。"""
|
||||||
@@ -2940,6 +3422,7 @@ class MainWindow(QWidget):
|
|||||||
self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s")
|
self.traffic_up.setText(f"↑{_fmt_bytes(up_rate)}/s")
|
||||||
self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s")
|
self.traffic_down.setText(f"↓{_fmt_bytes(down_rate)}/s")
|
||||||
self.traffic_total.setText(_fmt_bytes(up_total + down_total))
|
self.traffic_total.setText(_fmt_bytes(up_total + down_total))
|
||||||
|
self._update_tray_menu()
|
||||||
def _check_update(self, manual=False):
|
def _check_update(self, manual=False):
|
||||||
"""后台检查服务器是否有新版本;manual=True 时无视「跳过检查更新」设置。"""
|
"""后台检查服务器是否有新版本;manual=True 时无视「跳过检查更新」设置。"""
|
||||||
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
|
# 先初始化结果,任何路径(含跳过)完成后 _on_update_done 都能安全读取
|
||||||
@@ -3156,6 +3639,9 @@ class MainWindow(QWidget):
|
|||||||
|
|
||||||
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
|
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
|
||||||
"""
|
"""
|
||||||
|
self._quitting = True
|
||||||
|
if self._tray is not None:
|
||||||
|
self._tray.hide()
|
||||||
if self._start_in_progress and self._start_cancel_event is not None:
|
if self._start_in_progress and self._start_cancel_event is not None:
|
||||||
self._start_cancel_event.set()
|
self._start_cancel_event.set()
|
||||||
try:
|
try:
|
||||||
@@ -3249,7 +3735,9 @@ def main():
|
|||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
icon = load_icon()
|
icon = load_icon()
|
||||||
app.setWindowIcon(icon)
|
app.setWindowIcon(icon)
|
||||||
app.setQuitOnLastWindowClosed(True)
|
# 主窗口可按设置隐藏到托盘;显式点击托盘“退出”或关闭行为为“退出”时,
|
||||||
|
# MainWindow.quit_app() 会负责结束 QApplication。
|
||||||
|
app.setQuitOnLastWindowClosed(False)
|
||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
window.show()
|
window.show()
|
||||||
|
|||||||
+415
-20
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal
|
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QVBoxLayout, QWidget,
|
QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
|
||||||
|
QButtonGroup, QMessageBox,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
|
from .theme import C_BG, C_BORDER, C_CARD, C_GREEN, C_RED, C_SUBTEXT, C_TEXT, C_TITLE
|
||||||
@@ -292,6 +294,180 @@ class WhitelistDialog(QDialog):
|
|||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FixedRouteServices:
|
||||||
|
"""域名路由编辑框所需的规则与节点服务。"""
|
||||||
|
|
||||||
|
load_icon: object
|
||||||
|
load_rules: object
|
||||||
|
available_nodes: object
|
||||||
|
validate_domain: object
|
||||||
|
save_rules: object
|
||||||
|
|
||||||
|
|
||||||
|
class FixedRouteDialog(QDialog):
|
||||||
|
"""为域名配置强制直连或指定节点代理。"""
|
||||||
|
|
||||||
|
def __init__(self, services: FixedRouteServices, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._services = services
|
||||||
|
self.setWindowTitle("域名路由")
|
||||||
|
self.setWindowIcon(services.load_icon())
|
||||||
|
self.setFixedSize(620, 530)
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QDialog {{ background-color: {C_BG}; }}
|
||||||
|
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||||||
|
QLineEdit, QComboBox {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||||||
|
padding: 5px 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: 5px 7px; 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: 5px 12px; font-size: 12px; font-weight: bold; }}
|
||||||
|
QPushButton#btnOk:hover {{ background-color: #5ed978; }}
|
||||||
|
QPushButton#btnSmall {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||||||
|
border-radius: 6px; padding: 4px 10px; font-size: 11px; }}
|
||||||
|
QPushButton#btnSmall:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||||
|
""")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(9)
|
||||||
|
layout.setContentsMargins(18, 14, 18, 14)
|
||||||
|
|
||||||
|
title = QLabel("域名路由")
|
||||||
|
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||||||
|
title.setAlignment(Qt.AlignCenter)
|
||||||
|
layout.addWidget(title)
|
||||||
|
hint = QLabel("example.com 匹配根域及子域;*.example.com 仅匹配子域")
|
||||||
|
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(16)
|
||||||
|
self.lb_err.hide()
|
||||||
|
layout.addWidget(self.lb_err)
|
||||||
|
|
||||||
|
self.list_widget = QListWidget()
|
||||||
|
self.list_widget.setToolTip("勾选启用规则;选中后可删除")
|
||||||
|
layout.addWidget(self.list_widget, 1)
|
||||||
|
|
||||||
|
domain_row = QHBoxLayout()
|
||||||
|
domain_row.setSpacing(7)
|
||||||
|
self.ed_domain = QLineEdit()
|
||||||
|
self.ed_domain.setPlaceholderText("域名,如 example.com 或 *.example.com")
|
||||||
|
self.ed_domain.returnPressed.connect(self._add_rule)
|
||||||
|
domain_row.addWidget(self.ed_domain, 1)
|
||||||
|
self.cb_action = QComboBox()
|
||||||
|
self.cb_action.addItem("强制不代理", "direct")
|
||||||
|
self.cb_action.addItem("强制节点代理", "node")
|
||||||
|
self.cb_action.currentIndexChanged.connect(self._on_action_changed)
|
||||||
|
domain_row.addWidget(self.cb_action)
|
||||||
|
self.cb_node = QComboBox()
|
||||||
|
self.cb_node.setMinimumWidth(190)
|
||||||
|
domain_row.addWidget(self.cb_node)
|
||||||
|
self.btn_add = QPushButton("添加")
|
||||||
|
self.btn_add.setObjectName("btnOk")
|
||||||
|
self.btn_add.clicked.connect(self._add_rule)
|
||||||
|
domain_row.addWidget(self.btn_add)
|
||||||
|
layout.addLayout(domain_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.addStretch()
|
||||||
|
self.btn_cancel = QPushButton("取消")
|
||||||
|
self.btn_cancel.setObjectName("btnSmall")
|
||||||
|
self.btn_cancel.clicked.connect(self.reject)
|
||||||
|
buttons.addWidget(self.btn_cancel)
|
||||||
|
self.btn_save = QPushButton("保存")
|
||||||
|
self.btn_save.setObjectName("btnOk")
|
||||||
|
self.btn_save.clicked.connect(self._save)
|
||||||
|
buttons.addWidget(self.btn_save)
|
||||||
|
layout.addLayout(buttons)
|
||||||
|
|
||||||
|
self._reload_nodes()
|
||||||
|
for rule in services.load_rules():
|
||||||
|
self._add_item(rule)
|
||||||
|
self._on_action_changed()
|
||||||
|
|
||||||
|
def _reload_nodes(self):
|
||||||
|
self.cb_node.clear()
|
||||||
|
for node in self._services.available_nodes():
|
||||||
|
self.cb_node.addItem(node["label"], node["ref"])
|
||||||
|
if not self.cb_node.count():
|
||||||
|
self.cb_node.addItem("暂无可用节点", None)
|
||||||
|
|
||||||
|
def _on_action_changed(self, *_):
|
||||||
|
self.cb_node.setEnabled(self.cb_action.currentData() == "node")
|
||||||
|
|
||||||
|
def _add_item(self, rule):
|
||||||
|
domain = str(rule.get("domain") or rule.get("value") or "").strip()
|
||||||
|
if not domain:
|
||||||
|
return
|
||||||
|
action = str(rule.get("action") or "direct")
|
||||||
|
node_label = str(rule.get("node_label") or "节点不可用")
|
||||||
|
target = "强制不代理" if action == "direct" else f"节点代理:{node_label}"
|
||||||
|
item = QListWidgetItem(f"{domain} → {target}")
|
||||||
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
||||||
|
item.setCheckState(Qt.Checked if rule.get("enabled", True) else Qt.Unchecked)
|
||||||
|
item.setData(Qt.UserRole, {
|
||||||
|
"domain": domain,
|
||||||
|
"action": action,
|
||||||
|
"node_ref": str(rule.get("node_ref") or ""),
|
||||||
|
})
|
||||||
|
self.list_widget.addItem(item)
|
||||||
|
|
||||||
|
def _add_rule(self):
|
||||||
|
domain, error = self._services.validate_domain(self.ed_domain.text())
|
||||||
|
if error:
|
||||||
|
self.lb_err.setText(error)
|
||||||
|
self.lb_err.show()
|
||||||
|
return
|
||||||
|
action = self.cb_action.currentData()
|
||||||
|
node_ref = self.cb_node.currentData() if action == "node" else ""
|
||||||
|
if action == "node" and not node_ref:
|
||||||
|
self.lb_err.setText("请先配置至少一个可用节点")
|
||||||
|
self.lb_err.show()
|
||||||
|
return
|
||||||
|
for index in range(self.list_widget.count()):
|
||||||
|
existing = self.list_widget.item(index).data(Qt.UserRole) or {}
|
||||||
|
if str(existing.get("domain") or "").lower() == domain.lower():
|
||||||
|
self.lb_err.setText("该域名规则已存在")
|
||||||
|
self.lb_err.show()
|
||||||
|
return
|
||||||
|
self.lb_err.hide()
|
||||||
|
self._add_item({
|
||||||
|
"domain": domain,
|
||||||
|
"action": action,
|
||||||
|
"node_ref": node_ref,
|
||||||
|
"node_label": self.cb_node.currentText() if action == "node" else "",
|
||||||
|
"enabled": True,
|
||||||
|
})
|
||||||
|
self.ed_domain.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)
|
||||||
|
rule = dict(item.data(Qt.UserRole) or {})
|
||||||
|
rule["enabled"] = item.checkState() == Qt.Checked
|
||||||
|
rules.append(rule)
|
||||||
|
self._services.save_rules(rules)
|
||||||
|
self.accept()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class JumpServices:
|
class JumpServices:
|
||||||
"""代理跳板管理所需的节点摘要与配置回调。"""
|
"""代理跳板管理所需的节点摘要与配置回调。"""
|
||||||
@@ -660,6 +836,94 @@ class ThirdPartyDialog(QDialog):
|
|||||||
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
|
self.lb_status.setStyleSheet(f"color: {color}; font-size: 11px;")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LogServices:
|
||||||
|
"""运行日志框所需的显式业务依赖。"""
|
||||||
|
|
||||||
|
load_icon: object
|
||||||
|
get_logs: object
|
||||||
|
|
||||||
|
|
||||||
|
class LogDialog(QDialog):
|
||||||
|
"""显示带时间戳的运行日志;窗口打开时会持续读取最新日志。"""
|
||||||
|
|
||||||
|
def __init__(self, services: LogServices, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._services = services
|
||||||
|
self.setWindowTitle("运行日志")
|
||||||
|
self.setWindowIcon(services.load_icon())
|
||||||
|
self.resize(760, 500)
|
||||||
|
self.setMinimumSize(560, 320)
|
||||||
|
self.setStyleSheet(f"""
|
||||||
|
QDialog {{ background-color: {C_BG}; }}
|
||||||
|
QLabel {{ color: {C_SUBTEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 11px; }}
|
||||||
|
QTextEdit {{ background-color: {C_CARD}; border: 1px solid {C_BORDER}; border-radius: 6px;
|
||||||
|
padding: 6px 8px; color: {C_TEXT}; font-family: Consolas, "Microsoft YaHei", monospace; font-size: 11px; }}
|
||||||
|
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
|
||||||
|
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
||||||
|
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
|
||||||
|
""")
|
||||||
|
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
layout.setContentsMargins(14, 12, 14, 12)
|
||||||
|
hint = QLabel("显示当前运行期间的最近日志,认证信息不会写入日志。")
|
||||||
|
layout.addWidget(hint)
|
||||||
|
|
||||||
|
self.text = QTextEdit()
|
||||||
|
self.text.setReadOnly(True)
|
||||||
|
self.text.setLineWrapMode(QTextEdit.NoWrap)
|
||||||
|
layout.addWidget(self.text, 1)
|
||||||
|
|
||||||
|
buttons = QHBoxLayout()
|
||||||
|
buttons.setSpacing(8)
|
||||||
|
buttons.addStretch()
|
||||||
|
copy = QPushButton("复制")
|
||||||
|
copy.clicked.connect(self._copy_logs)
|
||||||
|
buttons.addWidget(copy)
|
||||||
|
export = QPushButton("导出")
|
||||||
|
export.clicked.connect(self._export_logs)
|
||||||
|
buttons.addWidget(export)
|
||||||
|
close = QPushButton("关闭")
|
||||||
|
close.clicked.connect(self.accept)
|
||||||
|
buttons.addWidget(close)
|
||||||
|
layout.addLayout(buttons)
|
||||||
|
|
||||||
|
self._last_text = None
|
||||||
|
self._refresh()
|
||||||
|
self._timer = QTimer(self)
|
||||||
|
self._timer.timeout.connect(self._refresh)
|
||||||
|
self._timer.start(500)
|
||||||
|
|
||||||
|
def _log_text(self):
|
||||||
|
text = self.text.toPlainText()
|
||||||
|
return "" if text == "暂无日志" else text
|
||||||
|
|
||||||
|
def _copy_logs(self):
|
||||||
|
QApplication.clipboard().setText(self._log_text())
|
||||||
|
|
||||||
|
def _export_logs(self):
|
||||||
|
default_name = f"rrpp-log-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt"
|
||||||
|
path, _ = QFileDialog.getSaveFileName(
|
||||||
|
self, "导出运行日志", default_name, "文本文件 (*.txt);;所有文件 (*.*)"
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
with open(path, "w", encoding="utf-8", newline="\n") as file:
|
||||||
|
file.write(self._log_text())
|
||||||
|
|
||||||
|
def _refresh(self):
|
||||||
|
text = "\n".join(self._services.get_logs()) or "暂无日志"
|
||||||
|
if text == self._last_text:
|
||||||
|
return
|
||||||
|
scrollbar = self.text.verticalScrollBar()
|
||||||
|
follow_tail = scrollbar.value() >= scrollbar.maximum() - 4
|
||||||
|
self.text.setPlainText(text)
|
||||||
|
self._last_text = text
|
||||||
|
if follow_tail:
|
||||||
|
scrollbar.setValue(scrollbar.maximum())
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SettingsServices:
|
class SettingsServices:
|
||||||
"""设置框所需的配置与业务回调。"""
|
"""设置框所需的配置与业务回调。"""
|
||||||
@@ -667,29 +931,37 @@ class SettingsServices:
|
|||||||
load_icon: object
|
load_icon: object
|
||||||
version: str
|
version: str
|
||||||
get_port: object
|
get_port: object
|
||||||
|
get_builtin_pac: object
|
||||||
get_update_via_proxy: object
|
get_update_via_proxy: object
|
||||||
get_skip_update_check: object
|
get_skip_update_check: object
|
||||||
|
get_node_display_mode: object
|
||||||
|
get_close_behavior: object
|
||||||
parse_port: object
|
parse_port: object
|
||||||
set_port: object
|
set_port: object
|
||||||
|
set_builtin_pac: object
|
||||||
set_update_via_proxy: object
|
set_update_via_proxy: object
|
||||||
set_skip_update_check: object
|
set_skip_update_check: object
|
||||||
|
set_node_display_mode: object
|
||||||
|
set_close_behavior: object
|
||||||
|
reset_user_config: object
|
||||||
open_whitelist: object
|
open_whitelist: object
|
||||||
open_jump: object
|
open_jump: object
|
||||||
open_third_party: object
|
open_third_party: object
|
||||||
|
open_logs: object
|
||||||
has_login: object
|
has_login: object
|
||||||
open_login: object
|
open_login: object
|
||||||
logout: object
|
logout: object
|
||||||
|
|
||||||
|
|
||||||
class SettingsDialog(QDialog):
|
class SettingsDialog(QDialog):
|
||||||
"""代理端口、更新方式、白名单与登录状态设置框。"""
|
"""代理端口、更新方式、域名路由与登录状态设置框。"""
|
||||||
|
|
||||||
def __init__(self, services: SettingsServices, parent=None, port_locked=False):
|
def __init__(self, services: SettingsServices, parent=None, port_locked=False):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._services = services
|
self._services = services
|
||||||
self.setWindowTitle("设置")
|
self.setWindowTitle("设置")
|
||||||
self.setWindowIcon(services.load_icon())
|
self.setWindowIcon(services.load_icon())
|
||||||
self.setFixedSize(300, 430)
|
self.setFixedSize(300, 470)
|
||||||
self.setStyleSheet(f"""
|
self.setStyleSheet(f"""
|
||||||
QDialog {{ background-color: {C_BG}; }}
|
QDialog {{ background-color: {C_BG}; }}
|
||||||
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
QLabel {{ color: {C_TEXT}; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 12px; }}
|
||||||
@@ -697,8 +969,8 @@ class SettingsDialog(QDialog):
|
|||||||
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
|
padding: 2px 8px; color: {C_TEXT}; font-size: 12px; }}
|
||||||
""")
|
""")
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setSpacing(12)
|
layout.setSpacing(7)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 8, 20, 8)
|
||||||
title = QLabel("设置")
|
title = QLabel("设置")
|
||||||
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
title.setStyleSheet(f"color: {C_TITLE}; font-size: 15px; font-weight: bold;")
|
||||||
title.setAlignment(Qt.AlignCenter)
|
title.setAlignment(Qt.AlignCenter)
|
||||||
@@ -725,12 +997,12 @@ class SettingsDialog(QDialog):
|
|||||||
self.lb_hint.setFixedHeight(14)
|
self.lb_hint.setFixedHeight(14)
|
||||||
self.lb_hint.setAlignment(Qt.AlignCenter)
|
self.lb_hint.setAlignment(Qt.AlignCenter)
|
||||||
layout.addWidget(self.lb_hint)
|
layout.addWidget(self.lb_hint)
|
||||||
layout.addStretch()
|
layout.addSpacing(2)
|
||||||
|
|
||||||
whitelist_row = QHBoxLayout()
|
whitelist_row = QHBoxLayout()
|
||||||
whitelist_label = QLabel("代理白名单")
|
whitelist_label = QLabel("域名路由")
|
||||||
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
whitelist_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
whitelist_label.setToolTip("命中列表的域名 / IP 将强制直连(不经过代理),每条规则可单独启用")
|
whitelist_label.setToolTip("为域名配置强制不代理,或强制经过指定节点;支持 *.example.com")
|
||||||
whitelist_row.addWidget(whitelist_label)
|
whitelist_row.addWidget(whitelist_label)
|
||||||
whitelist_row.addStretch()
|
whitelist_row.addStretch()
|
||||||
self.btn_whitelist = QPushButton("配置")
|
self.btn_whitelist = QPushButton("配置")
|
||||||
@@ -750,7 +1022,7 @@ class SettingsDialog(QDialog):
|
|||||||
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
|
jump_label.setToolTip("把当前可用节点串成 2-3 跳链路;最后一个节点作为对外出口")
|
||||||
jump_row.addWidget(jump_label)
|
jump_row.addWidget(jump_label)
|
||||||
jump_row.addStretch()
|
jump_row.addStretch()
|
||||||
self.btn_jump = QPushButton("添加")
|
self.btn_jump = QPushButton("配置")
|
||||||
self.btn_jump.setCursor(Qt.PointingHandCursor)
|
self.btn_jump.setCursor(Qt.PointingHandCursor)
|
||||||
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
|
self.btn_jump.setStyleSheet(self.btn_whitelist.styleSheet())
|
||||||
self.btn_jump.clicked.connect(self._open_jump)
|
self.btn_jump.clicked.connect(self._open_jump)
|
||||||
@@ -758,18 +1030,29 @@ class SettingsDialog(QDialog):
|
|||||||
layout.addLayout(jump_row)
|
layout.addLayout(jump_row)
|
||||||
|
|
||||||
third_party_row = QHBoxLayout()
|
third_party_row = QHBoxLayout()
|
||||||
third_party_label = QLabel("第三方")
|
third_party_label = QLabel("第三方订阅")
|
||||||
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
third_party_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
|
third_party_label.setToolTip("添加、更新或删除第三方 RRPP Client JSON 订阅")
|
||||||
third_party_row.addWidget(third_party_label)
|
third_party_row.addWidget(third_party_label)
|
||||||
third_party_row.addStretch()
|
third_party_row.addStretch()
|
||||||
self.btn_third_party = QPushButton("添加")
|
self.btn_third_party = QPushButton("配置")
|
||||||
self.btn_third_party.setCursor(Qt.PointingHandCursor)
|
self.btn_third_party.setCursor(Qt.PointingHandCursor)
|
||||||
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
|
self.btn_third_party.setStyleSheet(self.btn_whitelist.styleSheet())
|
||||||
self.btn_third_party.clicked.connect(self._open_third_party)
|
self.btn_third_party.clicked.connect(self._open_third_party)
|
||||||
third_party_row.addWidget(self.btn_third_party)
|
third_party_row.addWidget(self.btn_third_party)
|
||||||
layout.addLayout(third_party_row)
|
layout.addLayout(third_party_row)
|
||||||
|
|
||||||
|
log_row = QHBoxLayout()
|
||||||
|
log_label = QLabel("日志")
|
||||||
|
log_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
log_label.setToolTip("查看连接域名、规则匹配、路由、错误与流量等运行信息")
|
||||||
|
log_row.addWidget(log_label)
|
||||||
|
log_row.addStretch()
|
||||||
|
self.btn_logs = QPushButton("查看")
|
||||||
|
self.btn_logs.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.btn_logs.setStyleSheet(self.btn_whitelist.styleSheet())
|
||||||
|
self.btn_logs.clicked.connect(self._open_logs)
|
||||||
|
log_row.addWidget(self.btn_logs)
|
||||||
port_row = QHBoxLayout()
|
port_row = QHBoxLayout()
|
||||||
port_label = QLabel("代理转发端口")
|
port_label = QLabel("代理转发端口")
|
||||||
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
port_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
@@ -781,6 +1064,21 @@ class SettingsDialog(QDialog):
|
|||||||
port_row.addWidget(self.ed_port)
|
port_row.addWidget(self.ed_port)
|
||||||
layout.addLayout(port_row)
|
layout.addLayout(port_row)
|
||||||
|
|
||||||
|
builtin_pac_row = QHBoxLayout()
|
||||||
|
builtin_pac_label = QLabel("使用内置 PAC")
|
||||||
|
builtin_pac_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
builtin_pac_label.setToolTip(
|
||||||
|
"系统使用固定的本地代理端口;转发器内部仍按 PAC 规则智能判断直连或代理"
|
||||||
|
)
|
||||||
|
builtin_pac_row.addWidget(builtin_pac_label)
|
||||||
|
builtin_pac_row.addStretch()
|
||||||
|
self.cb_builtin_pac = ToggleSwitch(checked=services.get_builtin_pac())
|
||||||
|
self.cb_builtin_pac.setToolTip(
|
||||||
|
"系统固定指向本地代理端口,转发器内部保留 PAC 智能分流"
|
||||||
|
)
|
||||||
|
builtin_pac_row.addWidget(self.cb_builtin_pac)
|
||||||
|
layout.addLayout(builtin_pac_row)
|
||||||
|
|
||||||
update_row = QHBoxLayout()
|
update_row = QHBoxLayout()
|
||||||
update_label = QLabel("使用代理更新")
|
update_label = QLabel("使用代理更新")
|
||||||
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
update_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
@@ -800,6 +1098,75 @@ class SettingsDialog(QDialog):
|
|||||||
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
|
self.cb_skip_update.setToolTip("跳过 PAC 数据与程序版本检查(节点更新与登录不受影响),被跳过的检查不输出日志")
|
||||||
skip_row.addWidget(self.cb_skip_update)
|
skip_row.addWidget(self.cb_skip_update)
|
||||||
layout.addLayout(skip_row)
|
layout.addLayout(skip_row)
|
||||||
|
|
||||||
|
option_style = f"""
|
||||||
|
QPushButton {{ background-color: {C_CARD}; color: {C_SUBTEXT}; border: 1px solid {C_BORDER};
|
||||||
|
border-radius: 6px; padding: 2px 8px; font-size: 11px; min-width: 32px; }}
|
||||||
|
QPushButton:hover {{ background-color: #e3e8ee; color: {C_TEXT}; }}
|
||||||
|
QPushButton:checked {{ background-color: {C_TITLE}; color: #ffffff; border-color: {C_TITLE}; }}
|
||||||
|
"""
|
||||||
|
|
||||||
|
node_display_row = QHBoxLayout()
|
||||||
|
node_display_label = QLabel("节点信息显示")
|
||||||
|
node_display_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
node_display_label.setToolTip("控制主页面节点副标题显示域名还是节点备注")
|
||||||
|
node_display_row.addWidget(node_display_label)
|
||||||
|
node_display_row.addStretch()
|
||||||
|
self.btn_node_domain = QPushButton("域名")
|
||||||
|
self.btn_node_remark = QPushButton("备注")
|
||||||
|
for button in (self.btn_node_domain, self.btn_node_remark):
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setCursor(Qt.PointingHandCursor)
|
||||||
|
button.setStyleSheet(option_style)
|
||||||
|
node_display_row.addWidget(button)
|
||||||
|
self.node_display_group = QButtonGroup(self)
|
||||||
|
self.node_display_group.setExclusive(True)
|
||||||
|
self.node_display_group.addButton(self.btn_node_domain)
|
||||||
|
self.node_display_group.addButton(self.btn_node_remark)
|
||||||
|
if services.get_node_display_mode() == "remark":
|
||||||
|
self.btn_node_remark.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.btn_node_domain.setChecked(True)
|
||||||
|
layout.addLayout(node_display_row)
|
||||||
|
|
||||||
|
close_behavior_row = QHBoxLayout()
|
||||||
|
close_behavior_label = QLabel("默认关闭行为")
|
||||||
|
close_behavior_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
close_behavior_label.setToolTip("点击主窗口关闭按钮时退出程序,或隐藏到系统托盘")
|
||||||
|
close_behavior_row.addWidget(close_behavior_label)
|
||||||
|
close_behavior_row.addStretch()
|
||||||
|
self.btn_close_exit = QPushButton("退出")
|
||||||
|
self.btn_close_tray = QPushButton("托盘")
|
||||||
|
for button in (self.btn_close_exit, self.btn_close_tray):
|
||||||
|
button.setCheckable(True)
|
||||||
|
button.setCursor(Qt.PointingHandCursor)
|
||||||
|
button.setStyleSheet(option_style)
|
||||||
|
close_behavior_row.addWidget(button)
|
||||||
|
self.close_behavior_group = QButtonGroup(self)
|
||||||
|
self.close_behavior_group.setExclusive(True)
|
||||||
|
self.close_behavior_group.addButton(self.btn_close_exit)
|
||||||
|
self.close_behavior_group.addButton(self.btn_close_tray)
|
||||||
|
if services.get_close_behavior() == "tray":
|
||||||
|
self.btn_close_tray.setChecked(True)
|
||||||
|
else:
|
||||||
|
self.btn_close_exit.setChecked(True)
|
||||||
|
layout.addLayout(close_behavior_row)
|
||||||
|
|
||||||
|
reset_row = QHBoxLayout()
|
||||||
|
reset_label = QLabel("重置用户配置")
|
||||||
|
reset_label.setStyleSheet(f"color: {C_SUBTEXT}; font-size: 12px;")
|
||||||
|
reset_label.setToolTip("重置本地偏好、域名路由、跳板和当前节点选择;不会删除登录和订阅")
|
||||||
|
reset_row.addWidget(reset_label)
|
||||||
|
reset_row.addStretch()
|
||||||
|
self.btn_reset = QPushButton("重置")
|
||||||
|
self.btn_reset.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.btn_reset.setStyleSheet(option_style)
|
||||||
|
self.btn_reset.setEnabled(not port_locked)
|
||||||
|
self.btn_reset.clicked.connect(self._reset_user_config)
|
||||||
|
reset_row.addWidget(self.btn_reset)
|
||||||
|
layout.addLayout(reset_row)
|
||||||
|
|
||||||
|
layout.addLayout(log_row)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
buttons = QHBoxLayout()
|
buttons = QHBoxLayout()
|
||||||
@@ -807,19 +1174,16 @@ class SettingsDialog(QDialog):
|
|||||||
buttons.addStretch()
|
buttons.addStretch()
|
||||||
self.btn_save = QPushButton("保存更改")
|
self.btn_save = QPushButton("保存更改")
|
||||||
self.btn_save.setStyleSheet(f"""
|
self.btn_save.setStyleSheet(f"""
|
||||||
QPushButton {{ background-color: {C_GREEN}; color: #fff; border: none; border-radius: 6px;
|
QPushButton {{ background-color: #d9dde3; color: #4f5965; border: none; border-radius: 6px;
|
||||||
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
padding: 5px 14px; font-size: 12px; font-weight: bold; }}
|
||||||
QPushButton:disabled {{ background-color: #2a3a2e; color: #666; }}
|
QPushButton:hover {{ background-color: #cdd3da; color: #3f4853; }}
|
||||||
|
QPushButton:disabled {{ background-color: #eceff2; color: #a8afb8; }}
|
||||||
""")
|
""")
|
||||||
self.btn_save.setEnabled(not port_locked)
|
self.btn_save.setEnabled(not port_locked)
|
||||||
self.btn_save.clicked.connect(self._save_settings)
|
self.btn_save.clicked.connect(self._save_settings)
|
||||||
buttons.addWidget(self.btn_save)
|
buttons.addWidget(self.btn_save)
|
||||||
self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
|
self.btn_logout = QPushButton("退出登录" if services.has_login() else "登录")
|
||||||
self.btn_logout.setStyleSheet(f"""
|
self.btn_logout.setStyleSheet(self.btn_save.styleSheet())
|
||||||
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.setToolTip("登录获取官方节点;退出登录不会删除第三方订阅")
|
||||||
self.btn_logout.clicked.connect(self._logout)
|
self.btn_logout.clicked.connect(self._logout)
|
||||||
buttons.addWidget(self.btn_logout)
|
buttons.addWidget(self.btn_logout)
|
||||||
@@ -855,6 +1219,30 @@ class SettingsDialog(QDialog):
|
|||||||
def _open_third_party(self):
|
def _open_third_party(self):
|
||||||
self._services.open_third_party(self)
|
self._services.open_third_party(self)
|
||||||
|
|
||||||
|
def _open_logs(self):
|
||||||
|
self._services.open_logs(self)
|
||||||
|
|
||||||
|
def _reset_user_config(self):
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
"重置用户配置",
|
||||||
|
"确定要恢复本地偏好、域名路由、跳板和当前节点选择的默认值吗?\n\n"
|
||||||
|
"登录凭据、节点缓存、第三方订阅和 PAC 数据不会被删除。",
|
||||||
|
QMessageBox.Yes | QMessageBox.No,
|
||||||
|
QMessageBox.No,
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.Yes:
|
||||||
|
return
|
||||||
|
self._services.reset_user_config()
|
||||||
|
self.ed_port.setText(str(self._services.get_port()))
|
||||||
|
self.cb_builtin_pac.setChecked(self._services.get_builtin_pac())
|
||||||
|
self.cb_update_proxy.setChecked(self._services.get_update_via_proxy())
|
||||||
|
self.cb_skip_update.setChecked(self._services.get_skip_update_check())
|
||||||
|
self.btn_node_domain.setChecked(True)
|
||||||
|
self.btn_close_exit.setChecked(True)
|
||||||
|
self.lb_hint.setText("用户配置已重置")
|
||||||
|
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||||||
|
|
||||||
def _save_settings(self):
|
def _save_settings(self):
|
||||||
port = self._services.parse_port(self.ed_port.text())
|
port = self._services.parse_port(self.ed_port.text())
|
||||||
if port is None:
|
if port is None:
|
||||||
@@ -862,8 +1250,15 @@ class SettingsDialog(QDialog):
|
|||||||
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
self.lb_hint.setStyleSheet(f"color: {C_RED}; font-size: 11px;")
|
||||||
return
|
return
|
||||||
self._services.set_port(port)
|
self._services.set_port(port)
|
||||||
|
self._services.set_builtin_pac(bool(self.cb_builtin_pac.isChecked()))
|
||||||
self._services.set_update_via_proxy(bool(self.cb_update_proxy.isChecked()))
|
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._services.set_skip_update_check(bool(self.cb_skip_update.isChecked()))
|
||||||
|
self._services.set_node_display_mode(
|
||||||
|
"remark" if self.btn_node_remark.isChecked() else "domain"
|
||||||
|
)
|
||||||
|
self._services.set_close_behavior(
|
||||||
|
"tray" if self.btn_close_tray.isChecked() else "exit"
|
||||||
|
)
|
||||||
self.ed_port.setText(str(port))
|
self.ed_port.setText(str(port))
|
||||||
self.lb_hint.setText("已保存,下次开启代理生效")
|
self.lb_hint.setText("已保存,下次开启代理生效")
|
||||||
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
self.lb_hint.setStyleSheet(f"color: {C_GREEN}; font-size: 11px;")
|
||||||
|
|||||||
Reference in New Issue
Block a user