修复:

- 检查更新可能失败
- 关闭代理时代理不会正确退出,表现为重启设备后代理设置异常
- 按关闭按钮关闭窗口时程序不会正确退出

新增功能:
- 多节点切换支持
- 更好的 PAC 规则
- 更多的配置项分离加载
This commit is contained in:
RoyirinRosmontis
2026-08-23 11:24:39 +08:00
parent 73f724f396
commit f518819d8a
+71 -23
View File
@@ -313,8 +313,9 @@ def proxy_off():
"""关闭系统代理并停止 TLS 转发器 / PAC 本地服务。"""
log("正在关闭系统代理...")
_run(["reg", "add", PROXY_REG, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"])
# 清除 ProxyServer 和 AutoConfigURL,确保 PAC / 固定代理两种模式都能正确关闭
# 清除 ProxyServer / ProxyOverride / AutoConfigURL,确保两种模式都清理干净
_run(["reg", "delete", PROXY_REG, "/v", "ProxyServer", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "ProxyOverride", "/f"], warn=False)
_run(["reg", "delete", PROXY_REG, "/v", "AutoConfigURL", "/f"], warn=False)
log("系统代理已关闭")
_notify_proxy_change()
@@ -375,10 +376,11 @@ def _traffic_snapshot():
with _traffic_lock:
return _traffic["up"], _traffic["down"]
def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False):
def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, smart=False, quiet=False):
"""启动本地认证转发器:127.0.0.1:<local_port> -> remote_host:remote_port(可选 TLS 包裹)。
smart=True 时启用 IP 智能分流(域名表/非中国 IP 走隧道,中国 IP 直连)。
quiet=True 时不输出启动日志(临时转发器用)。
"""
global _tls_forwarder
_tls_stop()
@@ -386,21 +388,24 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
# 服务器证书为 Let's Encrypt 正式证书,默认上下文完整校验
ctx = ssl.create_default_context()
svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# Windows 上 Hyper-V/WSL 的端口保留区间会动态变动,bind 可能瞬时被拒
# (WinError 10013/10048);重试 3 次,间隔 1 秒
# (WinError 10013/10048);重试 3 次,间隔 1 秒,每次用全新 socket
last_err = None
svr = None
for attempt in range(3):
svr = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
svr.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
svr.bind(("127.0.0.1", _TLS_LOCAL_PORT))
break
except OSError as e:
last_err = e
svr.close()
svr = None
if attempt < 2:
log(f"端口 {_TLS_LOCAL_PORT} 绑定失败: {e},1 秒后重试 ({attempt + 1}/3)")
time.sleep(1)
else:
if svr is None:
raise last_err
svr.listen(32)
svr.settimeout(1)
@@ -420,7 +425,8 @@ def _tls_start(remote_host, remote_port=443, use_tls=True, bypass_lan=False, sma
t = threading.Thread(target=_run, daemon=True)
t.start()
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})")
if not quiet:
log(f"本地转发器已启动 ({proto} 127.0.0.1:{_TLS_LOCAL_PORT} -> {remote_host}:{remote_port})")
_HTTP_METHODS = (b"CONNECT ", b"GET ", b"POST ", b"PUT ", b"DELETE ",
b"HEAD ", b"OPTIONS ", b"PATCH ")
@@ -811,6 +817,19 @@ def _tls_stop():
pass
_tls_forwarder = None
def _ensure_forwarder():
"""确保本地转发器在运行;未运行时启动临时全隧道转发器(静默),返回是否由本次启动。"""
if _tls_forwarder:
return False
_tls_start(_endpoint_domain(), _endpoint_port(), use_tls=True,
bypass_lan=False, smart=False, quiet=True)
return True
def _release_forwarder(started_here):
"""停止由 _ensure_forwarder 启动的临时转发器;非本次启动则保持不动。"""
if started_here:
_tls_stop()
def is_proxy_on():
"""系统代理是否生效:固定代理已开启,或 AutoConfigURL 指向本地 PAC 服务。"""
r = _run(["reg", "query", PROXY_REG, "/v", "ProxyEnable"], warn=False)
@@ -1113,8 +1132,10 @@ class MainWindow(QWidget):
def closeEvent(self, event):
"""关闭窗口时自动清理代理和隧道。"""
self.quit_app()
event.accept()
try:
self.quit_app()
finally:
event.accept() # 即使清理异常窗口也必须关闭
# ── 开关操作 ──
def _set_toggle_button_style(self, is_start=True):
@@ -1375,10 +1396,20 @@ class MainWindow(QWidget):
return
url = f"https://{domain}/latest_version.json"
log("正在检查更新...")
req = urllib.request.Request(url)
auth = _proxy_auth_basic()
req.add_header("Authorization", f"Basic {auth}")
data = urllib.request.urlopen(req, timeout=10).read().decode("utf-8-sig")
# 显式走本地转发器隧道,不依赖系统代理状态:默认 opener 读注册表代理设置
# (异常退出残留 127.0.0.1 代理 -> 连接被拒),不识别 PAC 的 AutoConfigURL,
# 直连服务器还可能被墙干扰 TLS 握手(SSL UNEXPECTED_EOF)
started_here = _ensure_forwarder()
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
req = urllib.request.Request(url)
auth = _proxy_auth_basic()
req.add_header("Authorization", f"Basic {auth}")
data = opener.open(req, timeout=10).read().decode("utf-8-sig")
finally:
_release_forwarder(started_here)
info = json.loads(data)
log("客户端认证成功")
self._latest_version = info.get("latest_version", "")
@@ -1421,12 +1452,20 @@ class MainWindow(QWidget):
log(f"正在下载新版本: {url}")
log(f"保存到: {save_path}")
req = urllib.request.Request(url)
auth = _proxy_auth_basic()
req.add_header("Authorization", f"Basic {auth}")
# 同更新检查:显式走本地转发器隧道,不依赖系统代理状态
started_here = _ensure_forwarder()
try:
opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"}))
req = urllib.request.Request(url)
auth = _proxy_auth_basic()
req.add_header("Authorization", f"Basic {auth}")
with urllib.request.urlopen(req, timeout=120) as resp:
data = resp.read()
with opener.open(req, timeout=120) as resp:
data = resp.read()
finally:
_release_forwarder(started_here)
if len(data) < 1024:
self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE")
@@ -1450,12 +1489,15 @@ class MainWindow(QWidget):
QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}")
def quit_app(self):
"""只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。"
"""只清理自己设置的系统代理:用户或其他软件设置的代理不受影响。
本程序异常退出残留的代理设置由下次启动时 _cleanup_stale_proxy() 处理。
"""
if self._proxy_set_by_us:
proxy_off()
try:
proxy_off()
except Exception:
pass # 清理失败也不能阻止退出,残留交给下次启动清理
QApplication.quit()
# ── 程序入口 ──────────────────────────────────────────
@@ -1491,9 +1533,12 @@ def _acquire_single_instance():
"""通过命名互斥体保证单实例运行;已有实例时返回 False。"""
global _SINGLE_INSTANCE_MUTEX
try:
kernel32 = ctypes.windll.kernel32
# 必须用 use_last_error 捕获真实错误码:直接调 GetLastError 可能被
# ctypes 内部调用重置(曾因此漏检已运行实例,导致双开互抢端口)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.restype = ctypes.c_void_p
_SINGLE_INSTANCE_MUTEX = kernel32.CreateMutexW(None, True, r"Local\RRPP_SingleInstance")
return kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS
return ctypes.get_last_error() != 183 # ERROR_ALREADY_EXISTS
except Exception:
return True # 互斥体创建失败时放行,避免把用户锁在门外
@@ -1543,7 +1588,10 @@ def main():
# 延迟重设一次图标兜底,避免首次启动任务栏图标丢失
QTimer.singleShot(2000, lambda: window.setWindowIcon(icon))
sys.exit(app.exec_())
# 退出必须彻底:此前点 X 后出现过进程残留(解释器收尾被残留线程/信号
# 竞态挂住),os._exit 直接终止进程——所有清理已在 quit_app 里完成
ret = app.exec_()
os._exit(ret)
if __name__ == "__main__":
main()