天天看点

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

  前几天用Nginx和PySide6做了一个软件版本升级程序,不过只能支持单文件升级。今天我们来实现一个多文件升级程序,Nginx的配置不再赘述,可以参考我之前文章(使用Nginx和PySide6(PyQt)实现程序版本升级)。该程序会通过MD5哈希值对比哪些文件需要更新,然后把更新文件下载到本地替换原文件以实现程序升级。

视频加载中...

准备工作以及升级流程如下:

1、PySide6(PyQt)程序打包,注意不要打成单独一个文件,在打包时主程序文件需要加入版本信息(请参考我之前发的文章《pyinstaller打包配置文件版本信息》)。那么如何知道我的主程序文件中是否包含版本信息呢?如图所示,查看文件属性中的详细信息可以看到

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

如果你的主程序文件包含这些版本信息,那就可以进行下一步了。

2、用自制的版本工具生成一个升级文件版本信息清单。

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

需要填入项目路径、主程序文件路径、版本号(自动读取)、更新内容。生成的版本信息清单是用json保存的,我这里命名为update.json,其中包含版本号、更新内容描述和文件列表,文件列表描述了文件名以及该文件的md5哈希值。放到项目根目录下。update.json的内容如下:

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

3、启动主程序,当前版本号为1.0.0

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

4、点击“检查更新”按钮,查看服务器上的最新版本。

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

最新版本信息就是从服务器上的update.json中获取到的。

5、点击“立即更新”按钮。

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

升级程序通过update.json中描述的文件清单和md5哈希值来检查本地文件有哪些需要更新,并把它们下载到一个临时文件夹。

6、下载完成后,主程序会自动退出。

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

7、主程序在退出的同时会启动update.bat批处理程序,用下载到临时文件夹的更新文件替换原来的文件。为什么要这样做呢?因为我们的python程序在运行的时候是无法替换它自己的,所以需要先关闭我们的所有程序,再通过bat批处理程序更新文件。更新完成后批处理程序会自动启动主程序,此时的版本已经升级为1.0.1

使用Nginx和PySide6(PyQt)实现程序版本升级(加强版)

到此,程序升级完成。

生成升级版本信息清单文件的工具代码如下:

# -*- coding: utf-8 -*-
import hashlib
import json
import os
import sys

from PySide6.QtCore import Qt, QDir
from PySide6.QtWidgets import (QApplication, QGridLayout, QHBoxLayout, QLabel,
                               QLineEdit, QPushButton, QSizePolicy, QSpacerItem,
                               QTextEdit, QVBoxLayout, QStyleFactory, QWidget, QFileDialog, QMessageBox)
from win32api import GetFileVersionInfo, LOWORD, HIWORD


class MainWindow(QWidget):

    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.resize(600, 400)
        self.setWindowTitle('Version Tool')

        self.verticalLayout = QVBoxLayout(self)
        self.gridLayout = QGridLayout()
        self.gridLayout.setVerticalSpacing(10)

        self.projectRootPathLabel = QLabel("项目路径:")
        self.projectRootPathLabel.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
        self.gridLayout.addWidget(self.projectRootPathLabel, 0, 0, 1, 1)

        self.projectRootPathLineEdit = QLineEdit(self)
        self.gridLayout.addWidget(self.projectRootPathLineEdit, 0, 1, 1, 1)

        self.projectRootPathBrowseButton = self.createButton('浏览...', self.browseProjectRootPath)
        self.gridLayout.addWidget(self.projectRootPathBrowseButton, 0, 2, 1, 1)

        self.mainProgramFilePathLabel = QLabel("主程序:")
        self.mainProgramFilePathLabel.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
        self.gridLayout.addWidget(self.mainProgramFilePathLabel, 1, 0, 1, 1)

        self.mainProgramFilePathLineEdit = QLineEdit(self)
        self.gridLayout.addWidget(self.mainProgramFilePathLineEdit, 1, 1, 1, 1)

        self.mainProgramFilePathBrowseButton = self.createButton('浏览...', self.browseMainProgramFile)
        self.gridLayout.addWidget(self.mainProgramFilePathBrowseButton, 1, 2, 1, 1)

        self.mainProgramFileVersionLabel = QLabel("版本号:")
        self.mainProgramFileVersionLabel.setAlignment(Qt.AlignRight | Qt.AlignTrailing | Qt.AlignVCenter)
        self.gridLayout.addWidget(self.mainProgramFileVersionLabel, 2, 0, 1, 1)

        self.mainProgramFileVersionLineEdit = QLineEdit(self)
        self.mainProgramFileVersionLineEdit.setEnabled(False)
        self.gridLayout.addWidget(self.mainProgramFileVersionLineEdit, 2, 1, 1, 2)

        self.versionDescriptionLabel = QLabel("更新内容:")
        self.gridLayout.addWidget(self.versionDescriptionLabel, 3, 0, 1, 1)
        self.versionDescriptionLabel.setAlignment(Qt.AlignLeading | Qt.AlignLeft | Qt.AlignTop)

        self.versionDescriptionTextEdit = QTextEdit(self)
        self.gridLayout.addWidget(self.versionDescriptionTextEdit, 3, 1, 1, 2)
        self.verticalLayout.addLayout(self.gridLayout)

        self.horizontalLayout = QHBoxLayout()
        self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout.addItem(self.horizontalSpacer)

        self.createVersionInfoButton = self.createButton("生成版本信息", self.createVersionInfo)
        self.horizontalLayout.addWidget(self.createVersionInfoButton)
        self.verticalLayout.addLayout(self.horizontalLayout)

    def createButton(self, text, member):
        button = QPushButton(text)
        button.clicked.connect(member)
        return button

    def openMessageDialog(self, title, message):
        msg_box = QMessageBox(QMessageBox.Icon.Information, title, message,
                              QMessageBox.StandardButton.NoButton, self)
        msg_box.addButton("确定", QMessageBox.ButtonRole.YesRole)
        msg_box.exec()

    def browseProjectRootPath(self):
        directory = QFileDialog.getExistingDirectory(self, "选择目录", QDir.currentPath())
        if not directory.endswith('/'):
            directory = directory + '/'
        self.projectRootPathLineEdit.setText(directory)

    def browseMainProgramFile(self):
        currentDir = QDir.currentPath()
        projectRootPathLine = self.projectRootPathLineEdit.text()
        if projectRootPathLine:
            currentDir = projectRootPathLine
        filePath, fileExts = QFileDialog.getOpenFileName(self, '选择文件', currentDir, "主程序文件(*.exe)")
        self.mainProgramFilePathLineEdit.setText(filePath)

        try:
            self.mainProgramFileVersionLineEdit.setText('')
            info = GetFileVersionInfo(filePath, os.sep)
            ms = info['FileVersionMS']
            ls = info['FileVersionLS']
            version = '%d.%d.%d' % (HIWORD(ms), LOWORD(ms), HIWORD(ls))
            # version = '%d.%d.%d.%d' % (HIWORD(ms), LOWORD(ms), HIWORD(ls), LOWORD(ls))
            self.mainProgramFileVersionLineEdit.setText(version)
        except Exception as e:
            self.openMessageDialog('错误', '获取不到文件版本信息,请检查。')

    def createVersionInfo(self):
        projectRootPath = self.projectRootPathLineEdit.text()
        version_info = dict()
        version_info['version'] = self.mainProgramFileVersionLineEdit.text()
        version_info['description'] = self.versionDescriptionTextEdit.toPlainText()
        version_info['filelist'] = create_file_list(projectRootPath)
        json_str = json.dumps(version_info, default=lambda x: x.__dict__, sort_keys=False, indent=4,
                              ensure_ascii=False)
        print(json_str, end='\n')
        with open(projectRootPath + 'update.json', 'w', encoding='UTF-8') as fw:
            fw.write(json_str)

        self.openMessageDialog('信息', '版本升级信息文件已生成。')


# 生成文件的MD5值,通过比较文件的MD5值,就可以判定两个文件是否一致
def make_hash(file_path):
    try:
        md5 = hashlib.md5()  # 创建一个md5算法对象
        with open(file_path, 'rb') as f:  # 打开一个文件,必须是'rb'模式打开
            while 1:
                data = f.read(1024)  # 由于是一个文件,每次只读取固定字节
                if data:  # 当读取内容不为空时对读取内容进行update
                    md5.update(data)
                else:  # 当整个文件读完之后停止update
                    break
        ret = md5.hexdigest()  # 获取这个文件的MD5值
    except OSError:
        print('Permission denied: %s' % file_path)
        return None
    return ret


def create_file_list(root_path):
    file_list = list()
    for main_dir, sub_dir, file_name_list in os.walk(root_path):
        dir_relative_path = main_dir[len(root_path):]
        if dir_relative_path:
            dir_info = dir_relative_path.replace("\\", "/") + '/'
            file_list.append(dir_info)

        for file_name in file_name_list:
            if file_name == 'update.json':
                continue
            file_path = os.path.join(main_dir, file_name)
            file_path = file_path.replace("\\", "/")
            file_relative_path = file_path[len(root_path):]
            file_list.append(f'{file_relative_path},{make_hash(file_path)}')
    return file_list


if __name__ == "__main__":
    app = QApplication([])
    app.setStyle(QStyleFactory.create('Fusion'))
    window = MainWindow()
    window.show()
    sys.exit(app.exec())
           

主程序分为main.py和update.py两个文件,代码如下:

import sys
from PySide6.QtWidgets import QWidget, QVBoxLayout, QApplication, QStyleFactory, QPushButton, QLabel, QHBoxLayout, \
    QSpacerItem, QSizePolicy

from update import UpdateDialog

APP_NAME = '升级演示程序'
APP_VERSION = '1.0.0'
APP_UPDATE_URL = 'http://127.0.0.1/UpdateDemo/'


class MainWindow(QWidget):

    def __init__(self, parent=None):
        super(MainWindow, self).__init__(parent)
        self.fileName = None
        self.fileSize = None
        self.datetime = None
        self.downloadURL = None

        self.resize(500, 100)
        self.setWindowTitle(APP_NAME)

        self.verticalLayout = QVBoxLayout(self)

        self.versionLabel = QLabel(f'当前版本: {APP_VERSION}')
        self.verticalLayout.addWidget(self.versionLabel)

        self.tipLabel = QLabel(self)
        self.verticalLayout.addWidget(self.tipLabel)

        self.horizontalLayout = QHBoxLayout()

        self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout.addItem(self.horizontalSpacer)

        self.checkUpdateButton = QPushButton('检查更新')
        self.horizontalLayout.addWidget(self.checkUpdateButton)

        self.horizontalSpacer2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout.addItem(self.horizontalSpacer2)
        self.verticalLayout.addLayout(self.horizontalLayout)

        self.verticalLayout.setStretch(1, 1)

        self.checkUpdateButton.clicked.connect(self.checkUpdate)

    def checkUpdate(self):
        dialog = UpdateDialog()
        dialog.setUpdateInfo(APP_NAME, APP_VERSION, APP_UPDATE_URL)
        dialog.exec()

    def showTip(self, message):
        self.tipLabel.setText(message)


if __name__ == "__main__":
    app = QApplication([])
    app.setStyle(QStyleFactory.create('Fusion'))
    window = MainWindow()
    window.show()
    sys.exit(app.exec())
           
import hashlib
import json
import os
import subprocess
import sys

import requests
from contextlib import closing
from PySide6 import QtCore
from PySide6.QtCore import QThread, Slot, Signal
from PySide6.QtGui import QFont
from PySide6.QtWidgets import QVBoxLayout, QLineEdit, \
    QLabel, QFormLayout, QTextEdit, QHBoxLayout, QSpacerItem, QSizePolicy, QPushButton, QDialog, QMessageBox
from packaging.version import Version

REQUEST_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36'


# 生成文件的MD5值,通过比较文件的MD5值,就可以判定两个文件是否一致
def make_hash(file_path):
    try:
        md5 = hashlib.md5()  # 创建一个md5算法对象
        with open(file_path, 'rb') as f:  # 打开一个文件,必须是'rb'模式打开
            while 1:
                data = f.read(1024)  # 由于是一个文件,每次只读取固定字节
                if data:  # 当读取内容不为空时对读取内容进行update
                    md5.update(data)
                else:  # 当整个文件读完之后停止update
                    break
        ret = md5.hexdigest()  # 获取这个文件的MD5值
    except OSError:
        print('Permission denied: %s' % file_path)
        return None
    return ret


class UpdateDialog(QDialog):

    def __init__(self, parent=None):
        super(UpdateDialog, self).__init__(parent)
        self.appName = None
        self.appVersion = None
        self.appUpdateRootUrl = None
        self.fileList = None

        self.resize(500, 300)
        self.setWindowTitle(f'升级程序')

        self.verticalLayout = QVBoxLayout(self)
        self.verticalLayout.setSpacing(10)

        self.horizontalLayout = QHBoxLayout()

        self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout.addItem(self.horizontalSpacer)

        self.checkUpdateResultLabel = QLabel(self)
        font = QFont()
        font.setPointSize(12)
        self.checkUpdateResultLabel.setFont(font)
        self.horizontalLayout.addWidget(self.checkUpdateResultLabel)

        self.horizontalSpacer2 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout.addItem(self.horizontalSpacer2)
        self.verticalLayout.addLayout(self.horizontalLayout)

        self.formLayout = QFormLayout()

        self.formLayout.setWidget(0, QFormLayout.LabelRole, QLabel('应用名称:'))

        self.appNameLineEdit = QLineEdit(self)
        self.appNameLineEdit.setReadOnly(True)
        self.formLayout.setWidget(0, QFormLayout.FieldRole, self.appNameLineEdit)

        self.formLayout.setWidget(1, QFormLayout.LabelRole, QLabel('当前版本:'))

        self.appVersionLineEdit = QLineEdit(self)
        self.appVersionLineEdit.setReadOnly(True)
        self.formLayout.setWidget(1, QFormLayout.FieldRole, self.appVersionLineEdit)

        self.formLayout.setWidget(2, QFormLayout.LabelRole, QLabel('最新版本:'))

        self.latestVersionLineEdit = QLineEdit(self)
        self.latestVersionLineEdit.setReadOnly(True)
        self.formLayout.setWidget(2, QFormLayout.FieldRole, self.latestVersionLineEdit)

        self.formLayout.setWidget(3, QFormLayout.LabelRole, QLabel('更新内容:'))

        self.descriptionTextEdit = QTextEdit(self)
        self.descriptionTextEdit.setReadOnly(True)
        self.formLayout.setWidget(3, QFormLayout.FieldRole, self.descriptionTextEdit)

        self.verticalLayout.addLayout(self.formLayout)

        self.horizontalLayout2 = QHBoxLayout()

        self.horizontalSpacer3 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout2.addItem(self.horizontalSpacer3)

        self.tipLabel = QLabel(self)
        self.verticalLayout.addWidget(self.tipLabel)

        self.updateButton = QPushButton('立即更新')
        self.updateButton.setEnabled(False)
        self.horizontalLayout2.addWidget(self.updateButton)
        self.horizontalSpacer4 = QSpacerItem(40, 20, QSizePolicy.Expanding, QSizePolicy.Minimum)
        self.horizontalLayout2.addItem(self.horizontalSpacer4)
        self.verticalLayout.addLayout(self.horizontalLayout2)

        self.checkUpdateThread = CheckUpdateThread(self)
        self.checkUpdateThread.start()

        self.downloadThread = DownloadThread(self)

        self.updateButton.clicked.connect(lambda: self.downloadThread.start())

    def setUpdateInfo(self, appName, appVersion, appUpdateRootUrl):
        self.appName = appName
        self.appVersion = appVersion
        self.appUpdateRootUrl = appUpdateRootUrl
        self.appNameLineEdit.setText(self.appName)
        self.appVersionLineEdit.setText(self.appVersion)

    @Slot(str, str, str)
    def showCheckUpdateResult(self, version, description, file_list):
        self.latestVersionLineEdit.setText(version)
        self.descriptionTextEdit.setText(description)
        self.fileList = file_list

        latestVersion = Version(version)
        currentVersion = Version(self.appVersion)
        if latestVersion > currentVersion:
            self.checkUpdateResultLabel.setText('发现新版本')
            self.updateButton.setEnabled(True)
        else:
            self.checkUpdateResultLabel.setText('未发现新版本')
            self.updateButton.setEnabled(False)

    @Slot(str)
    def showTip(self, message):
        self.tipLabel.setText(message)

    @Slot(str)
    def exit(self, code):
        if code == 0:
            msg_box = QMessageBox(QMessageBox.Icon.Information, "确认", "即将重新启动程序以完成更新",
                                  QMessageBox.StandardButton.NoButton, self)
            msg_box.addButton("确定", QMessageBox.ButtonRole.YesRole)
            msg_box.exec()

            # os.popen('update.bat')
            subprocess.Popen("update.bat", shell=True)

            sys.exit(0)


# 信号对象
class Communicate(QtCore.QObject):
    # 创建一个信号
    showTipSignal = Signal(str)
    checkUpdateSignal = Signal(str, str, list)
    exitSignal = Signal(int)


class CheckUpdateThread(QThread):
    def __init__(self, parent=None):
        QThread.__init__(self, parent)
        self.parent = parent
        self.signals = Communicate()
        self.signals.checkUpdateSignal.connect(parent.showCheckUpdateResult)

    def run(self):
        # self.signals.tipSignal.emit('正在检查更新...')
        appUpdateJsonUrl = self.parent.appUpdateRootUrl + 'update.json'
        with closing(requests.get(appUpdateJsonUrl, headers={"User-Agent": REQUEST_USER_AGENT},
                                  stream=True)) as response:
            update_json = json.loads(response.text)
            version = update_json['version']
            description = update_json['description']
            file_list = update_json['filelist']
        self.signals.checkUpdateSignal.emit(version, description, file_list)


class DownloadThread(QThread):
    def __init__(self, parent=None):
        QThread.__init__(self, parent)
        self.parent = parent
        self.tempDir = 'temp/'  # 创建临时文件夹存放更新文件
        self.signals = Communicate()
        self.signals.showTipSignal.connect(parent.showTip)
        self.signals.exitSignal.connect(parent.exit)

    def run(self):
        if not os.path.exists(self.tempDir):
            os.mkdir(self.tempDir)

        for file in self.parent.fileList:
            if file.endswith('/'):
                dirName = self.tempDir + file
                if not os.path.exists(dirName):
                    os.mkdir(dirName)
            else:
                fileName, hash_value = file.split(',')
                if os.path.exists(fileName):
                    md5 = make_hash(fileName)
                    # print(f'{hash_value} --> {md5} {hash_value == md5}')
                    if hash_value == md5:
                        continue

                self.downloadFile(fileName)
        self.signals.showTipSignal.emit('更新完毕。')
        self.signals.exitSignal.emit(0)

    def downloadFile(self, fileName):
        fileUrl = self.parent.appUpdateRootUrl + fileName
        savePath = self.tempDir + fileName
        print(fileUrl)
        with closing(requests.get(fileUrl, headers={"User-Agent": REQUEST_USER_AGENT},
                                  stream=True)) as response:
            chunk_size = 1024  # 单次请求最大值
            content_size = int(response.headers['content-length'])  # 内容体总大小
            data_count = 0
            with open(savePath, "wb") as f:
                for data in response.iter_content(chunk_size=chunk_size):
                    f.write(data)
                    data_count = data_count + len(data)
                    now_jd = (data_count / content_size) * 100
                    # print("\r 文件下载进度:%d%%(%d/%d) - %s" % (now_jd, data_count, content_size, fileName), end=" ")
                    self.signals.showTipSignal.emit(
                        f'正在更新:{fileName} ({data_count}字节/{content_size}字节) {round(now_jd, 2)}%')
                # print('\r')
           

update.bat代码如下:

@echo off
echo updating...
xcopy temp %~dp0\ /e/y/i
rd /s/q temp
start main.exe           

由于时间原因,代码基本上没什么注释,如果感兴趣欢迎留言交流!