""" 软件自动更新模块 - 检查版本:GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware - 下载新版 exe,替换当前程序后重启 """ import os import sys import subprocess import tempfile from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication import urllib.request UPDATE_CHECK_URL = "https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware" def _current_version() -> str: try: import importlib m = importlib.import_module("__main__") return getattr(m, "__VERSION__", "0.0.0") except Exception: return "0.0.0" def _is_newer(latest: str, current: str) -> bool: try: def _parse(v): return tuple(int(x) for x in v.strip().lstrip("v").split(".")) return _parse(latest) > _parse(current) except Exception: return latest != current class _CheckWorker(QThread): result = pyqtSignal(dict) error = pyqtSignal(str) def run(self): try: import json with urllib.request.urlopen(UPDATE_CHECK_URL, timeout=10) as resp: body = json.loads(resp.read().decode()) if body.get("code") == 200: self.result.emit(body["data"]) else: self.error.emit(body.get("msg", "接口返回异常")) except Exception as e: self.error.emit(str(e)) class _DownloadWorker(QThread): progress = pyqtSignal(int) finished = pyqtSignal(str) error = pyqtSignal(str) def __init__(self, url: str, dest: str): super().__init__() self._url = url self._dest = dest def run(self): try: def _reporthook(count, block_size, total_size): if total_size > 0: pct = min(100, int(count * block_size * 100 / total_size)) self.progress.emit(pct) urllib.request.urlretrieve(self._url, self._dest, _reporthook) self.progress.emit(100) self.finished.emit(self._dest) except Exception as e: self.error.emit(str(e)) def _replace_and_restart(new_exe: str): """ onedir 模式:只替换 exe 本身,dll 等文件不变。 用 PowerShell 后台脚本等待原进程退出后替换并重启,完全无窗口。 """ if not getattr(sys, "frozen", False): subprocess.Popen([new_exe]) QApplication.quit() return current_exe = sys.executable pid = os.getpid() ps_script = f""" $pid_target = {pid} $src = '{new_exe.replace(chr(92), chr(92)*2)}' $dst = '{current_exe.replace(chr(92), chr(92)*2)}' # 等待原进程退出,最多 30 秒 $waited = 0 while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{ Start-Sleep -Milliseconds 500 $waited += 0.5 }} # 重试覆盖,最多 10 次 $ok = $false for ($i = 0; $i -lt 10; $i++) {{ try {{ Copy-Item -Path $src -Destination $dst -Force $ok = $true break }} catch {{ Start-Sleep -Milliseconds 800 }} }} if ($ok) {{ Remove-Item $src -Force -ErrorAction SilentlyContinue Start-Process $dst }} """ tmp = tempfile.NamedTemporaryFile( delete=False, suffix=".ps1", mode="w", encoding="utf-8" ) tmp.write(ps_script) tmp.close() subprocess.Popen( [ "powershell", "-WindowStyle", "Hidden", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", tmp.name, ], creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NO_WINDOW, ) QApplication.quit() class Updater(QObject): def __init__(self, parent=None): super().__init__(parent) self._parent_widget = parent def check(self, silent_if_latest: bool = False): self._silent = silent_if_latest self._worker = _CheckWorker() self._worker.result.connect(self._on_check_result) self._worker.error.connect(self._on_check_error) self._worker.start() def _on_check_result(self, data: dict): current = _current_version() latest = data.get("latestVersion", "") url = data.get("downloadUrl", "") if not _is_newer(latest, current): if not self._silent: QMessageBox.information(self._parent_widget, "检查更新", f"当前已是最新版本 v{current}") return notes = data.get("releaseNotes", "") or "" msg = f"发现新版本 v{latest}(当前 v{current})" if notes: msg += f"\n\n更新内容:\n{notes}" msg += "\n\n是否立即下载更新?" ret = QMessageBox.question( self._parent_widget, "发现新版本", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes, ) if ret != QMessageBox.StandardButton.Yes: return self._url = url self._download(url) def _on_check_error(self, err: str): if not self._silent: QMessageBox.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") def _download(self, url: str): # 下载到原 exe 同目录,避免跨盘 move 失败 if getattr(sys, "frozen", False): dest_dir = os.path.dirname(sys.executable) else: dest_dir = tempfile.gettempdir() dest = os.path.join(dest_dir, "_update_new.exe") self._progress = QProgressDialog("正在下载更新...", "取消", 0, 100, self._parent_widget) self._progress.setWindowTitle("下载更新") self._progress.setMinimumDuration(0) self._progress.setValue(0) self._dl_worker = _DownloadWorker(url, dest) self._dl_worker.progress.connect(self._progress.setValue) self._dl_worker.finished.connect(self._on_download_done) self._dl_worker.error.connect(self._on_download_error) self._progress.canceled.connect(self._dl_worker.terminate) self._dl_worker.start() def _on_download_done(self, path: str): self._progress.close() _replace_and_restart(path) def _on_download_error(self, err: str): self._progress.close() QMessageBox.critical(self._parent_widget, "下载失败", f"下载更新失败:\n{err}")