优化程序
This commit is contained in:
1 parent
68e8eb91a7
commit
6df29c9254
27 files changed
+1974
-203
No files matched your search
+154
-52
@@ -1,16 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
软件自动更新模块
|
||||
- 检查版本:GET https://api.yunzer.cn/api/softwareupgrade/check?code=niumasortware
|
||||
- 下载新版 exe,替换当前程序后重启
|
||||
软件自动更新模块 —— 主程序负责"下载 + 启动外部更新器 + 退出",
|
||||
真正的替换/重启由 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 ctypes
|
||||
import base64
|
||||
import argparse # noqa: F401 # update_helper 用 PyQt6 环境也行
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtWidgets import QProgressDialog, QMessageBox, QApplication
|
||||
import urllib.request
|
||||
@@ -77,57 +81,152 @@ class _DownloadWorker(QThread):
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
def _replace_and_restart(new_exe: str):
|
||||
if not getattr(sys, "frozen", False):
|
||||
subprocess.Popen([new_exe])
|
||||
# ────────── 启动外部更新器 ─────────────────────────────────
|
||||
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
|
||||
|
||||
current_exe = sys.executable
|
||||
# ─── 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()
|
||||
|
||||
ps_script = f"""
|
||||
$pid_target = {pid}
|
||||
$src = '{new_exe.replace(chr(92), chr(92)*2)}'
|
||||
$dst = '{current_exe.replace(chr(92), chr(92)*2)}'
|
||||
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
|
||||
|
||||
$waited = 0
|
||||
while ((Get-Process -Id $pid_target -ErrorAction SilentlyContinue) -and $waited -lt 30) {{
|
||||
Start-Sleep -Milliseconds 500
|
||||
$waited += 0.5
|
||||
}}
|
||||
_log_update_request(downloaded_path, dst_exe, pid)
|
||||
|
||||
$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
|
||||
}}
|
||||
}}
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
# 给 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)
|
||||
@@ -143,7 +242,7 @@ class Updater(QObject):
|
||||
def _on_check_result(self, data: dict):
|
||||
current = _current_version()
|
||||
latest = data.get("latestVersion", "")
|
||||
url = data.get("downloadUrl", "")
|
||||
url = data.get("downloadUrls", {}).get("windows") or data.get("downloadUrl", "")
|
||||
|
||||
if not _is_newer(latest, current):
|
||||
if not self._silent:
|
||||
@@ -167,11 +266,14 @@ class Updater(QObject):
|
||||
dialog_style.warning(self._parent_widget, "检查更新失败", f"无法连接更新服务器:\n{err}")
|
||||
|
||||
def _download(self, url: str):
|
||||
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")
|
||||
# 统一下载到系统 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("下载更新")
|
||||
@@ -187,7 +289,7 @@ class Updater(QObject):
|
||||
|
||||
def _on_download_done(self, path: str):
|
||||
self._progress.close()
|
||||
_replace_and_restart(path)
|
||||
_launch_update_helper(path)
|
||||
|
||||
def _on_download_error(self, err: str):
|
||||
self._progress.close()
|
||||
|
||||
Reference in new issue
Block a user