天天看點

使用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           

由于時間原因,代碼基本上沒什麼注釋,如果感興趣歡迎留言交流!