优化程序

This commit is contained in:
hero920103 committed 2026-10-12 14:57:46 +08:00
1 parent 68e8eb91a7
commit 6df29c9254
27 files changed
+1974 -203

No files matched your search

+154 -52
View File
@@ -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()