# -*- coding: utf-8 -*- """ 软件自动更新模块 —— 主程序负责"下载 + 启动外部更新器 + 退出", 真正的替换/重启由 update_helper.py 完成(独立进程,不随主程序死)。 更新包格式(自动识别): .exe → onefile 直接替换启动器,清理遗留 _internal/ 死目录 .zip → 解压整包,备份旧目录 → 原子替换 → 清理 检查接口: GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware """ import os import sys import subprocess import tempfile import json import argparse # noqa: F401 # update_helper 用 PyQt6 环境也行 from PyQt6.QtCore import QThread, pyqtSignal, QObject from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication import urllib.request import ui.dialog_style as dialog_style 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: 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 _launch_update_helper(downloaded_path: str) -> None: """下载完成 → 启动 update_helper 独立进程 → 主程序退出。 update_helper 负责:等待主进程退出 → 替换文件/目录 → 启动新版本 → 清理。""" is_frozen = getattr(sys, "frozen", False) if not is_frozen: # 开发态:无法自更新(运行时不能替换自己),只提示用户下载 dialog_style.information( None, "开发模式", "开发环境无法自动更新。\n请手动下载新版本,解压后替换原目录。", ) QApplication.quit() return # ─── onefile vs onedir 路径差异 ──────────────────────── # onefile 模式:sys.executable = _MEIPASS 里的临时副本,需要用 GetModuleFileNameW 找原始 exe # onedir 模式:sys.executable 就是真实路径,直接用 if hasattr(sys, "_MEIPASS"): # onefile:原始 exe 路径 import ctypes from ctypes import wintypes buf = ctypes.create_unicode_buffer(wintypes.MAX_PATH) ctypes.windll.kernel32.GetModuleFileNameW(None, buf, wintypes.MAX_PATH) dst_exe = buf.value else: dst_exe = sys.executable # ─── 包类型匹配检查 ─────────────────────────────────── # 防止 onefile(100MB) ↔ onedir 启动器(几MB) 混更导致更新后跑不起来 try: cur_size = os.path.getsize(dst_exe) new_size = os.path.getsize(downloaded_path) size_diff_abs = abs(cur_size - new_size) # onefile 通常 > 50MB,onedir 启动器通常 < 10MB,差 > 30MB 就报警 if size_diff_abs > 30 * 1024 * 1024: ret = dialog_style.warning( None, "更新包格式异常", f"下载的更新包大小({new_size / 1024 / 1024:.1f} MB)" f"与当前程序({cur_size / 1024 / 1024:.1f} MB)差异较大," f"可能是包格式不匹配(如 onefile vs onedir 混更)。\n\n" f"继续更新可能导致程序无法启动。是否仍要继续?", ) if ret != QMessageBox.StandardButton.Yes: try: os.remove(downloaded_path) except Exception: pass return except Exception: pass # ─── 找 update_helper.exe ───────────────────────────── # onedir: 与主 exe 同级 # onefile: 打包进 onefile exe 解压到 _MEIPASS,需复制到系统 temp 防止清理 app_dir = os.path.dirname(os.path.abspath(dst_exe)) helper_exe = os.path.join(app_dir, "update_helper.exe") helper_py = os.path.join(app_dir, "_internal", "update_helper.py") # onefile 解压目录里的 helper meipass_helper = None if hasattr(sys, "_MEIPASS"): meipass_helper = os.path.join(sys._MEIPASS, "update_helper.exe") if not os.path.exists(helper_exe) and os.path.exists(meipass_helper): # 从 _MEIPASS 复制到系统 temp(_MEIPASS 在主进程退出后可能被清理) import tempfile tmp_helper = os.path.join(tempfile.gettempdir(), "niumasoft_update_helper.exe") try: import shutil shutil.copy2(meipass_helper, tmp_helper) helper_exe = tmp_helper print(f"[updater] copied helper from _MEIPASS to {tmp_helper}") except Exception as e: print(f"[updater] copy helper failed: {e}") helper_exe = meipass_helper # 退而求其次,直接用 _MEIPASS 里的 # 如果 exe 不存在,尝试用 python 跑 py 脚本(开发态兜底) if not os.path.exists(helper_exe) and not os.path.exists(helper_py): # 最后兜底:找项目根目录的 update_helper.py fallback = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "update_helper.py") if os.path.exists(fallback): helper_py = fallback pid = os.getpid() if os.path.exists(helper_exe): cmd = [ helper_exe, "--src", downloaded_path, "--dst", dst_exe, "--pid", str(pid), "--restart", "--mode", "auto", ] elif os.path.exists(helper_py): cmd = [ sys.executable if os.path.exists(sys.executable) and sys.executable.lower().endswith("python.exe") else "python", helper_py, "--src", downloaded_path, "--dst", dst_exe, "--pid", str(pid), "--restart", "--mode", "auto", ] else: # 什么都找不到,放弃 dialog_style.warning(None, "更新失败", "找不到 update_helper 更新器组件。") return _log_update_request(downloaded_path, dst_exe, pid) try: # CREATE_NEW_CONSOLE:子进程创建独立控制台(不受父进程生命周期影响) # CREATE_NO_WINDOW: 控制台窗口不显示(静默运行) # 不用 DETACHED_PROCESS——它在 Python GUI 程序里不可靠,父进程退出时子进程可能被系统回收 subprocess.Popen( cmd, creationflags=subprocess.CREATE_NEW_CONSOLE | subprocess.CREATE_NO_WINDOW, ) except Exception as e: dialog_style.warning(None, "更新失败", f"无法启动更新器:\n{e}") return # 给 update_helper 一点时间就位(OpenProcess 还没拿到句柄就退了主进程, # WaitForSingleObject 会立刻返回 0,这其实正好是我们想要的,但留 1 秒保险) import time as _t _t.sleep(0.5) QApplication.quit() def _log_update_request(src: str, dst: str, pid: int) -> None: """在 update_helper 同目录写一份请求 JSON,方便计划任务/管理员提权模式读取。""" try: app_dir = os.path.dirname(os.path.abspath(sys.executable)) req = { "src": src, "dst": dst, "pid": pid, "restart": True, "ts": __import__("time").time(), } with open(os.path.join(app_dir, "update_request.json"), "w", encoding="utf-8") as f: json.dump(req, f, ensure_ascii=False, indent=2) except Exception: pass 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("downloadUrls", {}).get("windows") or data.get("downloadUrl", "") if not _is_newer(latest, current): if not self._silent: dialog_style.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 = dialog_style.question(self._parent_widget, "发现新版本", msg) if ret != QMessageBox.StandardButton.Yes: return self._download(url) def _on_check_error(self, err: str): if not self._silent: dialog_style.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}") def _download(self, url: str): # 统一下载到系统 temp 目录(不污染安装目录、避免文件锁冲突) tmp = tempfile.gettempdir() # 根据 URL 后缀决定文件名 name = os.path.basename(url.split("?")[0]) or "_niumasoft_update" if not os.path.splitext(name)[1]: # 不知道是什么,先存成 zip(update_helper 会自动识别) name += ".zip" dest = os.path.join(tmp, name) 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() _launch_update_helper(path) def _on_download_error(self, err: str): self._progress.close() dialog_style.warning(self._parent_widget, "下载失败", f"下载更新失败:\n{err}")