Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01de67610d | ||
|
|
384a6107a8 | ||
|
|
fb47f9e1d2 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "999909181618",
|
"version": "999909181706",
|
||||||
"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"
|
||||||
|
|||||||
+121
-3
@@ -1,6 +1,7 @@
|
|||||||
# -*- 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 (
|
||||||
@@ -14,7 +15,8 @@ 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,
|
||||||
|
ThirdPartyDialog, ThirdPartyServices,
|
||||||
WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices,
|
WhitelistDialog, WhitelistServices, FixedRouteDialog, FixedRouteServices,
|
||||||
JumpDialog, JumpServices,
|
JumpDialog, JumpServices,
|
||||||
)
|
)
|
||||||
@@ -933,17 +935,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):
|
||||||
@@ -2192,10 +2239,30 @@ 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
|
||||||
@@ -2226,16 +2293,24 @@ 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:
|
||||||
|
target_text = f"{target[0]}:{target[1]}"
|
||||||
|
log_detail(f"[连接 #{connection_id}] 收到请求: {request_type} {target_text},客户端={peer_text}")
|
||||||
forced_rule = _route_rule_for_host(target[0])
|
forced_rule = _route_rule_for_host(target[0])
|
||||||
if forced_rule and forced_rule.get("action") == "direct":
|
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 判定。
|
# 显式强制不代理优先于 PAC 的域名表和 IP 判定。
|
||||||
direct_target = (target[0], target[1])
|
direct_target = (target[0], target[1])
|
||||||
|
route_summary = f"强制直连 {target_text}"
|
||||||
elif forced_rule and forced_rule.get("action") == "node":
|
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)
|
forced_node = _fixed_route_node(forced_rule)
|
||||||
if forced_node is None:
|
if forced_node is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -2247,22 +2322,44 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
route_jump_chain = _jump_chain(forced_node)
|
route_jump_chain = _jump_chain(forced_node)
|
||||||
# 固定节点规则必须走指定节点,不允许 PAC 把目标改为直连。
|
# 固定节点规则必须走指定节点,不允许 PAC 把目标改为直连。
|
||||||
direct_target = None
|
direct_target = None
|
||||||
|
route_summary = (
|
||||||
|
f"强制多跳 {_chain_detail(route_jump_chain)}"
|
||||||
|
if route_jump_chain else
|
||||||
|
f"强制节点 {route_remote_host}:{route_remote_port}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
|
log_detail(f"[连接 #{connection_id}] 规则匹配: 否(未命中自定义域名路由)")
|
||||||
direct_target = _direct_connect_target(
|
direct_target = _direct_connect_target(
|
||||||
target[0], target[1], bypass_lan, smart)
|
target[0], target[1], bypass_lan, smart)
|
||||||
allow_direct_fallback = (direct_target is not None and
|
allow_direct_fallback = (direct_target is not None and
|
||||||
_is_smart_china_direct(target[0], bypass_lan, smart))
|
_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, route_remote_host, route_remote_port, ctx, use_tls,
|
direct_target, allow_direct_fallback, route_remote_host, route_remote_port, ctx, use_tls,
|
||||||
jump_chain=route_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")
|
||||||
@@ -2278,10 +2375,12 @@ def _tls_relay(client, remote_host, remote_port, ctx, use_tls, bypass_lan=False,
|
|||||||
)
|
)
|
||||||
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:
|
||||||
@@ -2298,6 +2397,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):
|
||||||
@@ -2306,33 +2406,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()
|
||||||
@@ -2342,6 +2449,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
|
||||||
@@ -2478,6 +2591,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
|
||||||
@@ -2504,6 +2621,7 @@ def _settings_services():
|
|||||||
open_whitelist=lambda parent: FixedRouteDialog(_fixed_route_services(), parent).exec_(),
|
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,
|
||||||
|
|||||||
+118
-16
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
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 (
|
||||||
QComboBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
|
QApplication, QComboBox, QDialog, QFileDialog, QHBoxLayout, QLabel, QLineEdit,
|
||||||
QPushButton, QVBoxLayout, QWidget,
|
QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, QTextEdit,
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
@@ -834,6 +835,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:
|
||||||
"""设置框所需的配置与业务回调。"""
|
"""设置框所需的配置与业务回调。"""
|
||||||
@@ -850,6 +939,7 @@ class SettingsServices:
|
|||||||
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
|
||||||
@@ -863,7 +953,7 @@ class SettingsDialog(QDialog):
|
|||||||
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, 350)
|
||||||
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; }}
|
||||||
@@ -871,8 +961,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(10)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 10, 20, 10)
|
||||||
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)
|
||||||
@@ -924,7 +1014,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)
|
||||||
@@ -932,18 +1022,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;")
|
||||||
@@ -974,6 +1075,7 @@ 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)
|
||||||
|
layout.addLayout(log_row)
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
|
|
||||||
buttons = QHBoxLayout()
|
buttons = QHBoxLayout()
|
||||||
@@ -981,19 +1083,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)
|
||||||
@@ -1029,6 +1128,9 @@ 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 _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:
|
||||||
|
|||||||
Reference in New Issue
Block a user