297 lines
12 KiB
Python
297 lines
12 KiB
Python
# -*- 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}")
|