diff --git a/lite/proxy_lite.py b/lite/proxy_lite.py index 08783dd..96b6528 100644 --- a/lite/proxy_lite.py +++ b/lite/proxy_lite.py @@ -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: -> 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() \ No newline at end of file