改进:显示更新下载进度并就地保存

This commit is contained in:
RoyirinRosmontis
2026-09-16 23:35:38 +08:00
parent 6fb2926627
commit 6673d28503
+92 -22
View File
@@ -6,7 +6,7 @@ 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, QCheckBox, QMenu, QDialog, QProgressDialog,
) )
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
@@ -1652,6 +1652,7 @@ class MainWindow(QWidget):
_op_done = pyqtSignal(str, bool) _op_done = pyqtSignal(str, bool)
_update_done = pyqtSignal() _update_done = pyqtSignal()
_update_download_done = pyqtSignal(bool, str) _update_download_done = pyqtSignal(bool, str)
_update_download_progress = pyqtSignal(int, int, int)
_status_ready = pyqtSignal(object) _status_ready = pyqtSignal(object)
_node_switched = pyqtSignal() # 启动校验时代替用户切换了节点(刷新标题栏) _node_switched = pyqtSignal() # 启动校验时代替用户切换了节点(刷新标题栏)
@@ -1665,6 +1666,7 @@ class MainWindow(QWidget):
self._start_in_progress = False self._start_in_progress = False
self._proxy_state_lock = threading.RLock() self._proxy_state_lock = threading.RLock()
self._status_busy = False # 防止状态轮询线程堆叠 self._status_busy = False # 防止状态轮询线程堆叠
self._update_progress_dialog = None
# 把全局 log() 函数接到 GUI 日志区 # 把全局 log() 函数接到 GUI 日志区
global _log_func global _log_func
_log_func = self.log_signal.emit _log_func = self.log_signal.emit
@@ -1672,6 +1674,7 @@ class MainWindow(QWidget):
self._op_done.connect(self._after_op) self._op_done.connect(self._after_op)
self._update_done.connect(self._on_update_done) self._update_done.connect(self._on_update_done)
self._update_download_done.connect(self._on_update_download_done) self._update_download_done.connect(self._on_update_download_done)
self._update_download_progress.connect(self._on_update_download_progress)
self._status_ready.connect(self._apply_status) self._status_ready.connect(self._apply_status)
self._node_switched.connect(self._on_node_switched) self._node_switched.connect(self._on_node_switched)
@@ -2338,8 +2341,35 @@ class MainWindow(QWidget):
QMessageBox.Yes, QMessageBox.Yes,
) )
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
self._show_update_download_progress()
threading.Thread(target=self._download_update, daemon=True).start() threading.Thread(target=self._download_update, daemon=True).start()
def _show_update_download_progress(self):
"""在 GUI 线程创建下载进度窗口;网络线程只通过 signal 回传状态。"""
dialog = QProgressDialog("正在准备下载更新…", None, 0, 100, self)
dialog.setWindowTitle("正在下载更新")
dialog.setWindowModality(Qt.WindowModal)
dialog.setMinimumDuration(0)
dialog.setAutoClose(False)
dialog.setAutoReset(False)
dialog.setValue(0)
dialog.show()
self._update_progress_dialog = dialog
def _on_update_download_progress(self, percent, downloaded, total):
"""把后台下载进度渲染到进度窗口。"""
dialog = self._update_progress_dialog
if dialog is None:
return
if total > 0:
dialog.setRange(0, 100)
dialog.setValue(max(0, min(100, percent)))
dialog.setLabelText(
f"正在下载更新… {percent}%({_fmt_bytes(downloaded)} / {_fmt_bytes(total)})")
else:
dialog.setRange(0, 0)
dialog.setLabelText(f"正在下载更新… 已下载 {_fmt_bytes(downloaded)}")
def _download_update(self): def _download_update(self):
"""后台下载新版本 EXE 到 EXE 所在目录。 """后台下载新版本 EXE 到 EXE 所在目录。
@@ -2357,58 +2387,98 @@ class MainWindow(QWidget):
# 对 URL 进行安全编码,兼容空格或已编码空格 # 对 URL 进行安全编码,兼容空格或已编码空格
url = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%') url = urllib.parse.quote(url, safe=':/?#[]@!$&\'()*+,;=%')
save_path = PERSIST_DIR / f"Royirin Rosmontis Private Proxy {self._latest_version}.exe" # 冻结程序必须放到当前 EXE 的目录,便于用户关闭旧版后就地替换;
# 源码调试则仍写入源码目录,避免误把测试文件写到 Python 安装目录。
if getattr(sys, 'frozen', False) or "__compiled__" in globals():
download_dir = Path(sys.executable).resolve().parent
else:
download_dir = PERSIST_DIR
save_path = download_dir / f"Royirin Rosmontis Private Proxy {self._latest_version}.exe"
part_path = save_path.with_suffix(save_path.suffix + ".part")
log(f"正在下载新版本: {url}") log(f"正在下载新版本: {url}")
log(f"保存到: {save_path}") log(f"保存到: {save_path}")
def fetch(target): use_proxy = _update_via_proxy()
# 同更新检查:按「使用代理更新」设置选择隧道或直连 started_here = _ensure_forwarder() if use_proxy else False
if _update_via_proxy(): try:
if use_proxy:
opener = urllib.request.build_opener(urllib.request.ProxyHandler( opener = urllib.request.build_opener(urllib.request.ProxyHandler(
{"http": f"{PROXY_HOST}:{PROXY_PORT}", {"http": f"{PROXY_HOST}:{PROXY_PORT}",
"https": f"{PROXY_HOST}:{PROXY_PORT}"})) "https": f"{PROXY_HOST}:{PROXY_PORT}"}))
else: else:
opener = _pac_no_proxy_opener() opener = _pac_no_proxy_opener()
with opener.open(urllib.request.Request(target), timeout=120) as resp:
return resp.read()
started_here = _ensure_forwarder() if _update_via_proxy() else False if part_path.exists():
part_path.unlink()
downloaded = 0
hasher = hashlib.sha256()
last_percent = -1
with opener.open(urllib.request.Request(url), timeout=120) as resp, part_path.open("wb") as fp:
try: try:
data = fetch(url) response_size = int(resp.headers.get("Content-Length") or 0)
except (TypeError, ValueError):
response_size = 0
total = self._asset_size or response_size
self._update_download_progress.emit(0, 0, total)
log(f"更新下载进度: 0%(0 / {_fmt_bytes(total)})" if total else "更新下载进度: 已开始")
while True:
chunk = resp.read(256 * 1024)
if not chunk:
break
fp.write(chunk)
hasher.update(chunk)
downloaded += len(chunk)
if total:
percent = min(100, downloaded * 100 // total)
if percent != last_percent:
last_percent = percent
log(f"更新下载进度: {percent}%({_fmt_bytes(downloaded)} / {_fmt_bytes(total)})")
self._update_download_progress.emit(percent, downloaded, total)
checksum_text = "" checksum_text = ""
if self._checksum_url: if self._checksum_url:
checksum_text = fetch(self._checksum_url).decode("ascii", "replace") checksum_url = urllib.parse.quote(
self._checksum_url, safe=':/?#[]@!$&\'()*+,;=%')
with opener.open(urllib.request.Request(checksum_url), timeout=120) as resp:
checksum_text = resp.read().decode("ascii", "replace")
finally: finally:
_release_forwarder(started_here) _release_forwarder(started_here)
if len(data) < 1024: if downloaded < 1024:
self._update_download_done.emit(False, "下载文件过小,可能不是有效的 EXE") raise RuntimeError("下载文件过小,可能不是有效的 EXE")
return if self._asset_size and downloaded != self._asset_size:
if self._asset_size and len(data) != self._asset_size: raise RuntimeError(
self._update_download_done.emit( f"下载不完整:实际 {downloaded} 字节,应为 {self._asset_size} 字节")
False, f"下载不完整:实际 {len(data)} 字节,应为 {self._asset_size} 字节")
return
if checksum_text: if checksum_text:
want = checksum_text.strip().split()[0].lower() want = checksum_text.strip().split()[0].lower()
got = hashlib.sha256(data).hexdigest() got = hasher.hexdigest()
if len(want) != 64 or want != got: if len(want) != 64 or want != got:
self._update_download_done.emit(False, "更新包校验失败(SHA-256 不匹配),已放弃") raise RuntimeError("更新包校验失败(SHA-256 不匹配),已放弃")
return
log("更新包 SHA-256 校验通过") log("更新包 SHA-256 校验通过")
save_path.write_bytes(data) part_path.replace(save_path)
log(f"新版本已下载: {save_path}") log(f"新版本已下载: {save_path}")
self._update_download_done.emit(True, str(save_path)) self._update_download_done.emit(True, str(save_path))
except Exception as e: except Exception as e:
try:
if 'part_path' in locals():
part_path.unlink(missing_ok=True)
except Exception:
pass
log(f"下载更新失败: {e}") log(f"下载更新失败: {e}")
self._update_download_done.emit(False, str(e)) self._update_download_done.emit(False, str(e))
def _on_update_download_done(self, success, message): def _on_update_download_done(self, success, message):
"""下载完成后提示用户手动替换 EXE。""" """下载完成后提示用户手动替换 EXE。"""
dialog = self._update_progress_dialog
self._update_progress_dialog = None
if dialog is not None:
dialog.close()
dialog.deleteLater()
if success: if success:
QMessageBox.information( QMessageBox.information(
self, "下载完成", self, "下载完成",
f"新版本已下载到:\n{message}\n\n请关闭本程序后,用该文件替换当前运行的程序,然后重新启动。" f"新版本已下载到当前程序所在文件夹:\n{message}\n\n请关闭本程序后,用该文件替换当前运行的程序,然后重新启动。"
) )
else: else:
QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}") QMessageBox.warning(self, "下载失败", f"无法下载新版本:\n{message}")