Files
niumasoftware/ui/updater.py
T
2026-10-12 14:57:46 +08:00

297 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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}")