From af1d3c37e8436d8ed1de15394ecf65121b9480d5 Mon Sep 17 00:00:00 2001 From: Tian <1203886034@qq.com> Date: Wed, 29 Apr 2026 23:16:10 +0800 Subject: [PATCH] =?UTF-8?q?modify:=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + Src/downloader.cpp | 241 +++++++++++++++++----- Src/downloader.h | 82 +++++++- Src/main.cpp | 15 +- Src/updaterdialog.cpp | 456 ++++++++++++++++++++++++------------------ Src/updaterdialog.h | 113 ++++++++++- 6 files changed, 657 insertions(+), 251 deletions(-) diff --git a/.gitignore b/.gitignore index b438dbd..641cfe7 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ users* *Release Makefile* # *.rc +updater_resource.rc ui_*.h moc_*.cpp *.o diff --git a/Src/downloader.cpp b/Src/downloader.cpp index b7d490d..e64f079 100644 --- a/Src/downloader.cpp +++ b/Src/downloader.cpp @@ -1,9 +1,41 @@ -#include "downloader.h" +#include "downloader.h" + #include -#include -#include +#include +#include #include +namespace +{ +/** + * @brief 从 Content-Disposition 响应头中提取服务器建议文件名。 + * @param headerValue 响应头原始值,可能包含百分号编码和 filename 字段。 + * @return 安全的纯文件名;解析失败返回空字符串。 + * + * 设计意图:只接收文件名部分,丢弃目录,避免服务器返回带路径的名称覆盖应用目录外文件。 + * 边界条件:响应头为空、格式不匹配、文件名为空时交给调用方继续使用本地预设文件名。 + */ +QString fileNameFromContentDisposition(const QVariant &headerValue) +{ + if(!headerValue.isValid()) + { + return QString(); + } + + const QString contentDisposition = + QString::fromUtf8(QByteArray::fromPercentEncoding(headerValue.toByteArray())); + const QRegularExpression regExp(R"(filename\*?=(?:UTF-8''|")?([^";]+))", + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch match = regExp.match(contentDisposition); + if(!match.hasMatch()) + { + return QString(); + } + + return QFileInfo(match.captured(1).trimmed()).fileName(); +} +} + Downloader::Downloader(QObject *parent) : QObject{parent} { @@ -19,89 +51,153 @@ void Downloader::setUrl(const QString &_url) void Downloader::startDownload(const QString &_url) { if(m_bIsDownloading) + { + emit doShowInfo(tr("download task is already running")); return; - m_bIsDownloading = true; + } + if(!_url.isEmpty()) - m_pUrl.setUrl(_url); + { + m_pUrl = QUrl(_url); + } - if(!dir.exists()) + if(!m_pUrl.isValid() || m_pUrl.isEmpty()) { - dir.mkpath("."); + emit doShowInfo(tr("download url is invalid")); + return; } - /* Rename old downloads */ - QString _path = dir.path() + "/" + m_fileName; - if(QFile::exists(_path)) + + if(m_fileName.trimmed().isEmpty()) { - QFile _file(_path); - _file.rename(_path + "_" + QDateTime::currentDateTime().toString("yyyy-MM-dd")); + const QString urlFileName = QFileInfo(m_pUrl.path()).fileName(); + if(urlFileName.isEmpty()) + { + emit doShowInfo(tr("download file name is empty")); + return; + } + m_fileName = urlFileName; } - QFile::remove(dir.filePath(m_fileName)); - QFile::remove(dir.filePath(m_fileName + ".part")); + m_bIsDownloading = true; + + if(!dir.exists() && !dir.mkpath(".")) + { + m_bIsDownloading = false; + emit doShowInfo(tr("create download directory failed")); + return; + } + + const QString targetPath = targetFilePath(); + const QString partPath = partialFilePath(); + + // 保留旧版本文件,便于更新失败时人工回滚;同一天多次下载时追加时间避免重名。 + if(QFile::exists(targetPath)) + { + QFile oldFile(targetPath); + const QString backupPath = targetPath + "_" + QDateTime::currentDateTime().toString("yyyy-MM-dd_hhmmss"); + if(!oldFile.rename(backupPath)) + { + emit doShowInfo(tr("backup old file failed: %1").arg(oldFile.errorString())); + } + } + + QFile::remove(partPath); + file.setFileName(partPath); + if(!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + m_bIsDownloading = false; + emit doShowInfo(tr("open download file failed: %1").arg(file.errorString())); + return; + } QNetworkRequest request; request.setUrl(m_pUrl); m_pReply = m_pNetWorkAccessManager->get(request); - connect(m_pReply, &QNetworkReply::downloadProgress, this, [&](qint64 received, qint64 total) + connect(m_pReply, &QNetworkReply::readyRead, this, [this]() { - emit doProgress(received, total); - if (total > 0) + if(!file.isOpen() || !m_pReply) + { + return; + } + + const QByteArray chunk = m_pReply->readAll(); + if(file.write(chunk) != chunk.size()) { - if(file.fileName() != (dir.filePath(m_fileName + ".part"))) - file.setFileName(dir.filePath(m_fileName + ".part")); - if(!file.isOpen()) - { - file.open(QIODevice::WriteOnly | QIODevice::Append); - } - file.write(m_pReply->readAll()); + emit doShowInfo(tr("write download file failed: %1").arg(file.errorString())); + m_pReply->abort(); } }); - connect(m_pReply, &QNetworkReply::metaDataChanged, this, [&]() + + connect(m_pReply, &QNetworkReply::downloadProgress, this, [this](qint64 received, qint64 total) + { + emit doProgress(received, total); + }); + + connect(m_pReply, &QNetworkReply::metaDataChanged, this, [this]() { - QString filename = ""; - QVariant variant = m_pReply->header( QNetworkRequest::ContentDispositionHeader ); - if ( variant.isValid() ) + const QString responseFileName = + fileNameFromContentDisposition(m_pReply->header(QNetworkRequest::ContentDispositionHeader)); + if(!responseFileName.isEmpty() && m_fileName.isEmpty()) { - QString contentDisposition = QByteArray::fromPercentEncoding( variant.toByteArray() ).constData(); - QRegularExpression regExp( "(.*)filename=\"(?.*)\"" ); - QRegularExpressionMatch match = regExp.match( contentDisposition ); - if ( match.hasMatch() ) - { - filename = match.captured( "filename" ); - } - m_fileName = filename; - auto localApplicationFilePath = QCoreApplication::applicationDirPath(); - file.setFileName(localApplicationFilePath + "/" + m_fileName); - }; + m_fileName = responseFileName; + } }); -// connect(m_pReply, SIGNAL(readyRead()), this, SLOT(onReadyRead())); - connect(m_pReply, &QNetworkReply::finished, this, [&]() + + connect(m_pReply, &QNetworkReply::finished, this, [this]() { - if(file.isOpen()) - file.close(); - if(!file.exists()) + QNetworkReply *finishedReply = m_pReply; + const QNetworkReply::NetworkError errorCode = finishedReply->error(); + // HTTP 状态码用于拦截 404/500 等服务器错误,避免把错误页面当作升级包落盘。 + const int httpStatusCode = + finishedReply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if(errorCode != QNetworkReply::NoError) + { + emit onError(errorCode); + emit doShowInfo(finishedReply->errorString()); + cleanupCurrentReply(true); + return; + } + if(httpStatusCode >= 400) { - emit doShowInfo("not exits"); + emit doShowInfo(tr("http download failed, status code: %1").arg(httpStatusCode)); + cleanupCurrentReply(true); + return; } + if(file.isOpen()) { - emit doShowInfo("not close"); + file.close(); + } + + const QString partPath = partialFilePath(); + const QString targetPath = targetFilePath(); + if(!QFile::exists(partPath)) + { + emit doShowInfo(tr("download temporary file not exists")); + cleanupCurrentReply(true); + return; } - if(!file.rename(dir.path() + "/" + m_fileName)) + + QFile::remove(targetPath); + if(!QFile::rename(partPath, targetPath)) { - emit doShowInfo("rename file failed"); + emit doShowInfo(tr("rename file failed")); + cleanupCurrentReply(true); + return; } - file.setFileName(dir.filePath(m_fileName)); + + file.setFileName(targetPath); m_bIsDownloading = false; + finishedReply->deleteLater(); + m_pReply = nullptr; emit doFinished(); }); - connect(m_pReply, SIGNAL(errorOccurred(QNetworkReply::NetworkError)), this, SIGNAL(onError(QNetworkReply::NetworkError))); } void Downloader::checkVersion() { - QNetworkRequest request(CHECK_URL); + QNetworkRequest request{QUrl(CHECK_URL)}; m_pNetWorkAccessManager->get(request); } @@ -122,5 +218,46 @@ const QString &Downloader::fileName() const void Downloader::setFileName(const QString &newFileName) { - m_fileName = newFileName; + m_fileName = QFileInfo(newFileName).fileName(); +} + +void Downloader::cleanupCurrentReply(bool removePartial) +{ + if(file.isOpen()) + { + file.close(); + } + + if(removePartial) + { + QFile::remove(partialFilePath()); + } + + if(m_pReply) + { + m_pReply->deleteLater(); + m_pReply = nullptr; + } + + m_bIsDownloading = false; +} + +QString Downloader::partialFilePath() const +{ + if(m_fileName.isEmpty()) + { + return QString(); + } + + return dir.filePath(m_fileName + ".part"); +} + +QString Downloader::targetFilePath() const +{ + if(m_fileName.isEmpty()) + { + return QString(); + } + + return dir.filePath(m_fileName); } diff --git a/Src/downloader.h b/Src/downloader.h index e0570d3..47e009c 100644 --- a/Src/downloader.h +++ b/Src/downloader.h @@ -4,7 +4,6 @@ #include #include #include -#include #include #include #include @@ -15,43 +14,118 @@ class Downloader : public QObject { Q_OBJECT public: + /** + * @brief 构造联网下载器。 + * @param parent Qt 父对象,负责随父对象析构自动释放网络管理器。 + * + * 设计意图:Downloader 只负责单文件下载和本地落盘,不直接操作界面。 + * 网络请求、QFile 都运行在创建它的线程中,外部跨线程调用时应使用信号槽队列连接, + * 避免在其它线程直接读写 QFile 或 QNetworkReply。 + */ explicit Downloader(QObject *parent = nullptr); + /** + * @brief 设置默认下载地址。 + * @param url 输入的远程文件 URL;为空或非法时不会主动下载。 + */ void setUrl(const QString &); - void startDownload(const QString &_url = NULL); - + /** + * @brief 开始下载文件。 + * @param url 可选下载地址;为空时使用 setUrl() 已保存的地址。 + * + * 输出通过 doProgress/doFinished/onError/doShowInfo 信号返回。 + * 边界条件:同一 Downloader 同时只允许一个请求;目标文件名为空或 URL 非法会直接报错。 + * 资源风险:下载过程写入 .part 临时文件,成功后替换为目标文件名;异常时关闭文件并清理网络对象。 + */ + void startDownload(const QString &url = QString()); + + /** + * @brief 触发版本检测请求。 + * + * 当前接口保留兼容旧调用;版本检测结果由 QNetworkAccessManager::finished 处理。 + */ void checkVersion(); + /** + * @brief 获取当前落盘文件对象。 + * @return 下载完成后的 QFile 引用;调用方只应读取文件名/路径,不应在下载中修改其打开状态。 + */ const QFile &getFile() const; + /** + * @brief 获取下载目录。 + * @return 应用程序目录;用于外部定位下载包。 + */ const QDir &getDir() const; + /** + * @brief 获取目标文件名。 + * @return 不包含目录的本地文件名。 + */ const QString &fileName() const; + /** + * @brief 设置目标文件名。 + * @param newFileName 不包含路径的文件名,避免远程响应头异常覆盖任意目录。 + */ void setFileName(const QString &newFileName); public slots: signals: + /** @brief 下载进度,received/total 单位为字节;total 为 -1 时表示服务器未给出总长度。 */ void doProgress(qint64, qint64); + + /** @brief 网络错误通知;错误发生后 Downloader 会关闭文件并释放 reply。 */ void onError(QNetworkReply::NetworkError); + + /** @brief 下载完成通知;仅在网络无错误且临时文件成功改名后发出。 */ void doFinished(); + /** @brief 文本状态通知,用于把边界条件、文件错误等信息显示到界面。 */ void doShowInfo(const QString&); private: + /** + * @brief 关闭当前请求并释放 QNetworkReply。 + * @param removePartial 为 true 时删除 .part 临时文件,常用于错误/取消场景。 + * + * 设计意图:统一网络对象和文件句柄的释放路径,避免 finished/error 分支重复清理导致二次关闭。 + */ + void cleanupCurrentReply(bool removePartial); + + /** + * @brief 计算临时下载文件的完整路径。 + * @return 当前目标文件名对应的 .part 路径;文件名为空时返回空字符串。 + */ + QString partialFilePath() const; + + /** + * @brief 计算最终下载文件的完整路径。 + * @return 当前目标文件名对应的最终路径;文件名为空时返回空字符串。 + */ + QString targetFilePath() const; + + /** @brief 网络访问管理器,归 Downloader 父子对象树管理,必须在同一线程使用。 */ QNetworkAccessManager *m_pNetWorkAccessManager{nullptr}; + + /** @brief 当前下载响应对象,finished 后通过 deleteLater 释放,避免悬空回调。 */ QNetworkReply *m_pReply{nullptr}; - bool m_bIsDownloading = false; //正在下载标识 + /** @brief 正在下载标识,防止重复点击造成多个请求同时写同一个文件。 */ + bool m_bIsDownloading = false; + /** @brief 当前下载 URL;startDownload 允许临时覆盖该地址。 */ QUrl m_pUrl{}; + /** @brief 本地目标文件名,仅保存文件名不保存目录,避免路径注入。 */ QString m_fileName = ""; + /** @brief 下载落盘文件句柄,下载中指向 .part 文件,完成后指向正式文件。 */ QFile file; + /** @brief 下载目录,默认应用程序目录;目录不存在时 startDownload 会创建。 */ QDir dir; }; diff --git a/Src/main.cpp b/Src/main.cpp index b74554e..09ea10f 100644 --- a/Src/main.cpp +++ b/Src/main.cpp @@ -1,12 +1,20 @@ -#include +#include +#include +#include //#include #include "updaterdialog.h" +/** + * @brief 加载更新器暗色样式。 + * + * 设计意图:样式资源不存在时只输出提示,不阻塞更新主流程。 + * 资源风险:QFile 为栈对象,函数退出自动关闭;读取失败时保留 Qt 默认样式。 + */ void loadQss() { QFile f(":qdarkstyle/dark/darkstyle.qss"); // QFile f(":qdarkstyle/light/lightstyle.qss"); - if (!f.exists()) + if(!f.exists()) { printf("Unable to set stylesheet, file not found\n"); } @@ -24,7 +32,8 @@ int main(int argc, char *argv[]) loadQss(); - if(argc > 1) + // 参数由主程序启动更新器时传入,数量不足时进入手动输入模式,避免访问 argv 越界。 + if(argc >= 5) { APPNAME = argv[1]; APPVERSION = argv[2]; diff --git a/Src/updaterdialog.cpp b/Src/updaterdialog.cpp index ed8cc38..8c4be11 100644 --- a/Src/updaterdialog.cpp +++ b/Src/updaterdialog.cpp @@ -1,12 +1,15 @@ -#include "updaterdialog.h" +#include "updaterdialog.h" #include "ui_updaterdialog.h" +#include #include +#include #include #include #include #include -#include +#include +#include #include @@ -18,16 +21,67 @@ QString APPNAME = ""; QString APPDATE = "0"; QString MODIFYCNT = "0"; +namespace +{ +/** + * @brief 比较两个 x.y.z 形式的版本号。 + * @param left 左侧版本号。 + * @param right 右侧版本号。 + * @return left < right 返回 -1,left == right 返回 0,left > right 返回 1。 + * + * 设计意图:统一版本比较规则,缺失段按 0 处理,非数字段也按 0 处理并保持保守行为。 + * 边界条件:空版本号小于任何非空版本号;两者均空则视为相等。 + */ +int compareVersionString(const QString &left, const QString &right) +{ + const QStringList leftParts = left.trimmed().split(".", Qt::SkipEmptyParts); + const QStringList rightParts = right.trimmed().split(".", Qt::SkipEmptyParts); + const int partCount = qMax(leftParts.count(), rightParts.count()); + + for(int i = 0; i < partCount; ++i) + { + const uint leftValue = (i < leftParts.count()) ? leftParts.at(i).toUInt() : 0U; + const uint rightValue = (i < rightParts.count()) ? rightParts.at(i).toUInt() : 0U; + if(leftValue < rightValue) + { + return -1; + } + if(leftValue > rightValue) + { + return 1; + } + } + + return 0; +} + +/** + * @brief 拼接基础 URL 和文件相对路径。 + * @param baseUrl 远程基础地址,允许带或不带末尾斜杠。 + * @param appName 应用名目录。 + * @param fileName 文件名。 + * @return 标准化后的下载 URL 字符串。 + */ +QString buildDownloadUrl(QString baseUrl, const QString &appName, const QString &fileName) +{ + if(!baseUrl.endsWith('/')) + { + baseUrl.append('/'); + } + + return baseUrl + appName + "/" + fileName; +} +} + UpdaterDialog::UpdaterDialog(QWidget *parent) : QDialog(parent), ui(new Ui::UpdaterDialog) { ui->setupUi(this); this->setWindowTitle("Updater"); - ui->btn_update->setEnabled(0); - ui->btn_update_patch->setEnabled(0); + setUpdateButtonsEnabled(false); - m_startTime = 0; + m_totalSize = 0; m_version = APPVERSION; ui->versionInput->setText(m_version); @@ -36,45 +90,36 @@ UpdaterDialog::UpdaterDialog(QWidget *parent) : ui->show->setEnabled(0); ui->label_2->setText(""); ui->label_3->setText(""); -// ui->label_4->setText(APPNAME); -// ui->versionInput->setEnabled(false); -// ui->show->appendPlainText(APPNAME); -// ui->show->appendPlainText(APPVERSION); -// ui->show->appendPlainText(APPDATE); -// ui->show->appendPlainText(MODIFYCNT); m_pDownloader = new Downloader(this); - connect(m_pDownloader, &Downloader::doShowInfo, this, [&](const QString & s) + connect(m_pDownloader, &Downloader::doShowInfo, this, [this](const QString &s) { showStatus(s); }); - connect(m_pDownloader, &Downloader::onError, this, [&](QNetworkReply::NetworkError e) + connect(m_pDownloader, &Downloader::onError, this, [this](QNetworkReply::NetworkError e) { - QMetaEnum metaEnum = QMetaEnum::fromType(); - QString str = QString(metaEnum.valueToKey(e)); + const QMetaEnum metaEnum = QMetaEnum::fromType(); + const QString str = QString::fromLatin1(metaEnum.valueToKey(e)); ui->show->appendPlainText(str); + setUpdateButtonsEnabled(true); }); - connect(m_pDownloader, &Downloader::doFinished, this, [&]() + connect(m_pDownloader, &Downloader::doFinished, this, [this]() { - uint _end_time = QDateTime::currentSecsSinceEpoch(); - uint _total_time = _end_time - m_startTime; - ui->label_3->setText("total time: " + QString::number(_total_time, 'g', 3) + - "s average speed: " + QString::number((m_totalSize / 1024.0 / _total_time), 'g', 3) + "kb/s"); ui->show->appendPlainText("download finished"); onDownloadFinished(); }); - connect(m_pDownloader, &Downloader::doProgress, this, [&](quint64 received, quint64 total) + connect(m_pDownloader, &Downloader::doProgress, this, [this](qint64 received, qint64 total) { - m_totalSize = total; - if (total > 0) + m_totalSize = total > 0 ? static_cast(total) : 0; + if(total > 0) { ui->progressBar->setMinimum(0); ui->progressBar->setMaximum(100); - ui->progressBar->setValue((received * 100) / total); + ui->progressBar->setValue(static_cast((received * 100) / total)); calculateSizes(received, total); calculateTimeRemaining(received, total); } @@ -83,319 +128,348 @@ UpdaterDialog::UpdaterDialog(QWidget *parent) : ui->progressBar->setMinimum(0); ui->progressBar->setMaximum(0); ui->progressBar->setValue(-1); - ui->show->appendPlainText(tr("Downloading Updates") + "..."); + ui->label_2->setText(tr("Downloading updates")); } }); } UpdaterDialog::~UpdaterDialog() { + delete m_UpdateProc; delete ui; } void UpdaterDialog::showStatus(const QString &s) { -// ui->show->clear(); ui->show->appendPlainText(s); } void UpdaterDialog::on_btn_check_clicked() { - APPNAME = ui->appNameInput->text(); - if(QString(APPNAME) == "") + APPNAME = ui->appNameInput->text().trimmed(); + if(APPNAME.isEmpty()) { ui->show->appendPlainText("Please input app name"); return; } + ui->show->clear(); - ui->btn_update->setEnabled(0); - ui->btn_update_patch->setEnabled(0); - //version - m_version = ui->versionInput->text(); + setUpdateButtonsEnabled(false); + + m_version = ui->versionInput->text().trimmed(); if(!m_pManager) { m_pManager = new QNetworkAccessManager(this); connect(m_pManager, SIGNAL(finished(QNetworkReply *)), this, SLOT(onCheckReply(QNetworkReply *))); } - QString _url = CHECK_URL.arg(APPNAME); - qDebug() << APPNAME << _url; - QNetworkRequest request(_url); + const QString url = CHECK_URL.arg(APPNAME); + QNetworkRequest request{QUrl(url)}; m_pManager->get(request); } bool UpdaterDialog::checkVersion(const QString &_localVersion, const QString &_remoteVersion) { - //版本号分割,1.1.2分为1 1 2 - QStringList _list_curr = _localVersion.split("."); - QStringList _list_lastest = _remoteVersion.split("."); - - //检查是否需要更新 - bool _isNotLatest = false; - //版本号为空 - if(_list_curr.count() == 0) + // 本地版本为空或为 0 时视为首次安装/未知版本,优先下载完整包降低补丁失败风险。 + if(_localVersion.trimmed().isEmpty() || _localVersion.trimmed() == "0") { m_bDownloadFullExe = true; return true; } - if(_list_curr.count() != _list_lastest.count()) - _isNotLatest = true; - for(int i = 0; i < _list_curr.count(); ++i) + const int versionCompareResult = compareVersionString(_localVersion, _remoteVersion); + if(versionCompareResult > 0) { - if(_list_curr[i].toUInt() < _list_lastest[i].toUInt()) - { - _isNotLatest = true; - } + return false; } - //版本号一致,检查日期 - if(!_isNotLatest) + bool isNotLatest = versionCompareResult < 0; + if(!isNotLatest) { + // 版本号一致时再比较构建日期和修改计数,兼容同版本热修复包。 if(m_lastChangeTime.toUInt() > APPDATE.toUInt()) { - _isNotLatest = true; + isNotLatest = true; } - //检查修改次数 - if(m_modifyCnt != "" && m_modifyCnt != " " && MODIFYCNT != "" && MODIFYCNT != " ") + if(!m_modifyCnt.trimmed().isEmpty() && !MODIFYCNT.trimmed().isEmpty()) { if(m_modifyCnt.toUInt() > MODIFYCNT.toUInt()) { - _isNotLatest = true; + isNotLatest = true; } } } - return _isNotLatest; + return isNotLatest; } void UpdaterDialog::onCheckReply(QNetworkReply *reply) { - /* There was a network error */ - if (reply->error() != QNetworkReply::NoError) + reply->deleteLater(); + + if(reply->error() != QNetworkReply::NoError) { - showStatus("check error"); + showStatus("check error: " + reply->errorString()); return; } - /* Try to create a JSON document from downloaded data */ - QJsonDocument document = QJsonDocument::fromJson(reply->readAll()); - - /* JSON is invalid */ - if (document.isNull()) + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll()); + if(document.isNull() || !document.isObject()) { showStatus("reply doc is null"); return; } - /* Get the platform information */ - QJsonObject updates = document.object().value("updates").toObject(); - QJsonObject platform = updates.value(PLATFORM).toObject(); + const QJsonObject updates = document.object().value("updates").toObject(); + const QJsonObject platform = updates.value(PLATFORM).toObject(); + if(platform.isEmpty()) + { + showStatus(tr("no update information for platform: %1").arg(PLATFORM)); + return; + } - /* Get update information */ m_changelog = platform.value("changelog").toString(); m_patchVersion = platform.value("patch-version").toString(); m_fullVersion = platform.value("full-version").toString(); m_lastChangeTime = platform.value("modify-time").toString(); m_modifyCnt = platform.value("modifyCnt").toString(); m_bDownloadFullExe = platform.value("download-full").toBool(); - if(m_version == "" || m_version == "0") //当前版本号为空 + m_patchBaseUrl = platform.value("patch-url").toString(); + m_fullBaseUrl = platform.value("full-url").toString(); + + if(m_version.isEmpty() || m_version == "0") { m_bDownloadFullExe = true; } - m_fileName = APPNAME + "-patch-v" + m_patchVersion + ".zip"; - m_patchUrl = platform.value("patch-url").toString() + APPNAME + "/" + APPNAME + "-patch-v" + m_patchVersion + ".zip"; - m_fullUrl = platform.value("full-url").toString() + APPNAME + "/" + APPNAME + "-full-v" + m_fullVersion + ".exe"; - QString _remoteVersion = m_bDownloadFullExe ? m_fullVersion : m_patchVersion; - bool _isNotLatest = checkVersion(m_version, _remoteVersion); + rebuildDownloadInfo(); + + const QString remoteVersion = m_bDownloadFullExe ? m_fullVersion : m_patchVersion; + const bool isNotLatest = checkVersion(m_version, remoteVersion); - if(_isNotLatest) + if(isNotLatest) { showStatus("======================================" + tr("\ncurrent version: v") + m_version + tr("\nmodify time: ") + APPDATE + tr("\nmodify cnt: ") + MODIFYCNT + "\n ------------------------------" - + tr("\nlatest version: v") + _remoteVersion + + tr("\nlatest version: v") + remoteVersion + tr("\nmodify time: ") + m_lastChangeTime + tr("\nmodify cnt: ") + m_modifyCnt + tr("\nchange log: ") + m_changelog - + "\n======================================" - ); + + "\n======================================"); + if(m_bDownloadFullExe) { showStatus("need download full exe"); - m_fileName = APPNAME + "-full-v" + m_patchVersion + ".exe"; } + showStatus(tr("click update button to update")); + setUpdateButtonsEnabled(true); } else { showStatus("======================================" + tr("\ncurrent software is up-to-date") - + tr("\nversion : v") + _remoteVersion + + tr("\nversion : v") + remoteVersion + tr("\nmodify time: ") + m_lastChangeTime + tr("\nmodify cnt: ") + m_modifyCnt + "\n======================================" + tr("\nno need to update") + tr("\nclick cancel button to quit")); + setUpdateButtonsEnabled(false); } - ui->btn_update->setEnabled(1); - ui->btn_update_patch->setEnabled(1); } void UpdaterDialog::calculateSizes(qint64 received, qint64 total) { - QString totalSize; - QString receivedSize; - - if (total < 1024) - totalSize = tr("%1 bytes").arg(total); - - else if (total < 1048576) - totalSize = tr("%1 KB").arg(round(total / 1024)); - - else - totalSize = tr("%1 MB").arg((total / 1048576.0)); - - if (received < 1024) - receivedSize = tr("%1 bytes").arg(received); - - else if (received < 1048576) - receivedSize = tr("%1 KB").arg(received / 1024); - - else - receivedSize = tr("%1 MB").arg(received / 1048576.0); + const QString totalSize = formatBytes(total); + const QString receivedSize = formatBytes(received); ui->label_2->setText(tr("Downloading updates") + " (" + receivedSize + " " + tr("of") + " " + totalSize + ")"); } -/** - * Uses two time samples (from the current time and a previous sample) to - * calculate how many bytes have been downloaded. - * - * Then, this function proceeds to calculate the appropiate units of time - * (hours, minutes or seconds) and constructs a user-friendly string, which - * is displayed in the dialog. - */ void UpdaterDialog::calculateTimeRemaining(qint64 received, qint64 total) { - static quint64 lastRec = 0; - static uint lastTime = 0; - uint _time = QDateTime::currentDateTime().toMSecsSinceEpoch(); - double _speed = (received - lastRec) / 1024.0 / (_time - lastTime) * 1000.0; - lastTime = _time; - lastRec = received; - uint difference = QDateTime::currentDateTime().toSecsSinceEpoch() - m_startTime; - - if (difference > 0) + if(total <= 0 || received <= 0) { - QString timeString; - qreal timeRemaining = (total - received) / (received / difference); - - if (timeRemaining > 7200) - { - timeRemaining /= 3600; - int hours = int(timeRemaining + 0.5); - - if (hours > 1) - timeString = tr("about %1 hours").arg(hours); - else - timeString = tr("about one hour"); - } - - else if (timeRemaining > 60) - { - timeRemaining /= 60; - int minutes = int(timeRemaining + 0.5); + return; + } - if (minutes > 1) - timeString = tr("%1 minutes").arg(minutes); - else - timeString = tr("1 minute"); - } + const qint64 currentMs = m_downloadTimer.elapsed(); + const qint64 elapsedSinceLast = currentMs - m_lastProgressMs; + double speed = 0.0; + if(elapsedSinceLast > 0) + { + speed = (received - m_lastProgressBytes) / 1024.0 / elapsedSinceLast * 1000.0; + } - else if (timeRemaining <= 60) - { - int seconds = int(timeRemaining + 0.5); + m_lastProgressMs = currentMs; + m_lastProgressBytes = received; - if (seconds > 1) - timeString = tr("%1 seconds").arg(seconds); - else - timeString = tr("1 second"); - } + const qint64 elapsedSeconds = qMax(1, currentMs / 1000); + qreal timeRemaining = (total - received) / (received / static_cast(elapsedSeconds)); - ui->label_3->setText(tr("speed: %1 kb/s ").arg(_speed) + tr("Time remaining") + ": " + timeString); + QString timeString; + if(timeRemaining > 7200) + { + timeRemaining /= 3600; + const int hours = int(timeRemaining + 0.5); + timeString = hours > 1 ? tr("about %1 hours").arg(hours) : tr("about one hour"); + } + else if(timeRemaining > 60) + { + timeRemaining /= 60; + const int minutes = int(timeRemaining + 0.5); + timeString = minutes > 1 ? tr("%1 minutes").arg(minutes) : tr("1 minute"); + } + else + { + const int seconds = int(timeRemaining + 0.5); + timeString = seconds > 1 ? tr("%1 seconds").arg(seconds) : tr("1 second"); } + + ui->label_3->setText(tr("speed: %1 kb/s ").arg(speed, 0, 'f', 1) + tr("Time remaining") + ": " + timeString); } void UpdaterDialog::on_btn_update_clicked() { + rebuildDownloadInfo(); m_pDownloader->setFileName(m_fileName); - m_startTime = QDateTime::currentDateTime().toSecsSinceEpoch(); + m_downloadTimer.restart(); + m_lastProgressMs = 0; + m_lastProgressBytes = 0; - QString _url = m_bDownloadFullExe ? m_fullUrl : m_patchUrl; - m_pDownloader->startDownload(_url); - qDebug() << _url; + const QString url = m_bDownloadFullExe ? m_fullUrl : m_patchUrl; + m_pDownloader->startDownload(url); } void UpdaterDialog::on_btn_cancel_clicked() { - if(!m_UpdateProc) + if(restartApplication()) { - m_UpdateProc = new QProcess; + QApplication::quit(); } -#if defined Q_OS_WIN - m_UpdateProc->setProgram(APPNAME + ".exe"); -#elif defined Q_OS_LINUX - m_UpdateProc->setProgram("./" + APPNAME); -#endif - m_UpdateProc->start(); - m_UpdateProc->waitForStarted(); - QApplication::quit(); } void UpdaterDialog::onDownloadFinished() { -// ui->show->appendPlainText(m_pDownloader->fileName()); -// ui->show->appendPlainText("m_bDownloadFullExe"); - auto localInformation = QMessageBox::question(this, "update", "download finished\nstart update?"); - if(localInformation == QMessageBox::Yes) + const qreal totalSeconds = qMax(0.001, m_downloadTimer.elapsed() / 1000.0); + if(m_totalSize > 0) { - QString _appName; - if(!m_bDownloadFullExe) - { - //解压文件 - QFileInfo f(m_pDownloader->getFile()); - QString _path = f.fileName(); - QZipReader * zipReader = new QZipReader(_path); - zipReader->extractAll(f.path()); - _appName = APPNAME + ".exe"; - } - else - { - _appName = m_pDownloader->fileName(); - } + ui->label_3->setText("total time: " + QString::number(totalSeconds, 'f', 1) + + "s average speed: " + QString::number((m_totalSize / 1024.0 / totalSeconds), 'g', 3) + "kb/s"); + } + + const auto localInformation = QMessageBox::question(this, "update", "download finished\nstart update?"); + if(localInformation != QMessageBox::Yes) + { + return; + } - QFileInfo f2(QCoreApplication::applicationDirPath() + "/" + _appName); - if(f2.exists()) + QString appName; + if(!m_bDownloadFullExe) + { + // 补丁包下载完成后在应用目录解压;解压失败时保留更新器,避免退出后主程序缺文件。 + QFileInfo fileInfo(m_pDownloader->getFile()); + QZipReader zipReader(fileInfo.absoluteFilePath()); + if(!zipReader.extractAll(fileInfo.absolutePath())) { - QDesktopServices::openUrl(f2.absoluteFilePath()); + showStatus("extract patch failed"); + return; } + + appName = APPNAME + ".exe"; + } + else + { + appName = m_pDownloader->fileName(); + } + + const QFileInfo appFile(QCoreApplication::applicationDirPath() + "/" + appName); + if(appFile.exists()) + { + QDesktopServices::openUrl(QUrl::fromLocalFile(appFile.absoluteFilePath())); QApplication::quit(); } + else + { + showStatus("update target file not exists: " + appFile.absoluteFilePath()); + } } void UpdaterDialog::on_btn_update_patch_clicked() { - m_pDownloader->setFileName(m_fileName); - m_startTime = QDateTime::currentDateTime().toSecsSinceEpoch(); m_bDownloadFullExe = false; - QString _url = m_patchUrl; - m_pDownloader->startDownload(_url); + rebuildDownloadInfo(); + m_pDownloader->setFileName(m_fileName); + m_downloadTimer.restart(); + m_lastProgressMs = 0; + m_lastProgressBytes = 0; + m_pDownloader->startDownload(m_patchUrl); +} + +void UpdaterDialog::setUpdateButtonsEnabled(bool enabled) +{ + ui->btn_update->setEnabled(enabled); + ui->btn_update_patch->setEnabled(enabled && !m_patchUrl.isEmpty()); +} + +void UpdaterDialog::rebuildDownloadInfo() +{ + const QString patchFileName = APPNAME + "-patch-v" + m_patchVersion + ".zip"; + const QString fullFileName = APPNAME + "-full-v" + m_fullVersion + ".exe"; + + m_patchUrl = buildDownloadUrl(m_patchBaseUrl, APPNAME, patchFileName); + m_fullUrl = buildDownloadUrl(m_fullBaseUrl, APPNAME, fullFileName); + m_fileName = m_bDownloadFullExe ? fullFileName : patchFileName; } +QString UpdaterDialog::formatBytes(qint64 bytes) const +{ + if(bytes < 0) + { + return tr("unknown"); + } + if(bytes < 1024) + { + return tr("%1 bytes").arg(bytes); + } + if(bytes < 1048576) + { + return tr("%1 KB").arg(QString::number(bytes / 1024.0, 'f', 1)); + } + + return tr("%1 MB").arg(QString::number(bytes / 1048576.0, 'f', 2)); +} + +bool UpdaterDialog::restartApplication() +{ + if(APPNAME.trimmed().isEmpty()) + { + showStatus("app name is empty"); + return false; + } + + QString program; +#if defined Q_OS_WIN + program = APPNAME + ".exe"; +#elif defined Q_OS_LINUX + program = "./" + APPNAME; +#else + program = APPNAME; +#endif + + // 取消更新后需要让原程序独立运行,使用 startDetached 避免更新器退出时影响子进程生命周期。 + if(!QProcess::startDetached(program, QStringList())) + { + showStatus("restart app failed"); + return false; + } + + return true; +} diff --git a/Src/updaterdialog.h b/Src/updaterdialog.h index 99562f3..ae83df7 100644 --- a/Src/updaterdialog.h +++ b/Src/updaterdialog.h @@ -2,6 +2,7 @@ #define UPDATERDIALOG_H #include +#include #include #include "downloader.h" @@ -22,56 +23,166 @@ class UpdaterDialog : public QDialog Q_OBJECT public: + /** + * @brief 构造更新对话框。 + * @param parent Qt 父窗口;Downloader/QNetworkAccessManager 随本窗口释放。 + * + * 设计意图:界面层负责版本检测、用户确认、补丁解压和启动新程序; + * 下载细节委托 Downloader,避免 UI 逻辑和网络落盘逻辑混在一起。 + */ explicit UpdaterDialog(QWidget *parent = nullptr); + + /** + * @brief 析构更新对话框。 + * + * Qt 父子对象会释放 Downloader、网络管理器等 QObject;手动 new 且无 parent 的进程对象会在析构中释放。 + */ ~UpdaterDialog(); + /** + * @brief 追加显示状态文本。 + * @param message 输入的人类可读状态信息,允许为空但不会清空已有日志。 + */ void showStatus(const QString &); + /** + * @brief 计算并显示下载字节量。 + * @param received 已接收字节数。 + * @param total 总字节数;小于等于 0 表示未知长度。 + */ void calculateSizes(qint64 received, qint64 total); + /** + * @brief 计算并显示下载速度和剩余时间。 + * @param received 已接收字节数。 + * @param total 总字节数;未知长度时不计算剩余时间。 + */ void calculateTimeRemaining(qint64 received, qint64 total); + + /** + * @brief 判断远程版本是否高于本地版本。 + * @param _localVersion 本地版本号,支持 x.y.z 形式。 + * @param _remoteVersion 远程版本号,支持 x.y.z 形式。 + * @return true 表示需要更新;false 表示本地已经是最新或远程版本无效。 + * + * 边界条件:版本段数量不一致时按缺失段为 0 比较,避免旧实现访问越界。 + */ bool checkVersion(const QString & _localVersion, const QString & _remoteVersion); private slots: + /** @brief “check” 按钮槽函数,发起远程 updates.json 检测请求。 */ void on_btn_check_clicked(); + + /** @brief 版本检测响应处理槽函数,解析 JSON 并刷新可更新状态。 */ void onCheckReply(QNetworkReply *); + /** @brief “update-full” 按钮槽函数,下载完整安装包或当前推荐包。 */ void on_btn_update_clicked(); + /** @brief “cancel” 按钮槽函数,重新启动原程序并退出更新器。 */ void on_btn_cancel_clicked(); + /** @brief 下载完成槽函数,按用户确认执行补丁解压或启动完整包。 */ void onDownloadFinished(); + /** @brief “update-patch” 按钮槽函数,强制下载增量补丁包。 */ void on_btn_update_patch_clicked(); private: + /** + * @brief 设置下载按钮状态。 + * @param enabled true 表示允许用户开始下载。 + */ + void setUpdateButtonsEnabled(bool enabled); + + /** + * @brief 构建下载文件名和 URL。 + * + * 输入来自 updates.json 和 APPNAME,全量包/补丁包统一在这里拼接,避免多处分支产生不一致文件名。 + */ + void rebuildDownloadInfo(); + + /** + * @brief 格式化字节数。 + * @param bytes 字节数。 + * @return 自动选择 B/KB/MB 的显示字符串。 + */ + QString formatBytes(qint64 bytes) const; + + /** + * @brief 启动当前应用程序。 + * + * Windows 下启动 APPNAME.exe,Linux 下启动 ./APPNAME;启动失败会显示错误并保留更新器。 + */ + bool restartApplication(); + + /** @brief UI 指针,由 setupUi 创建,析构时手动 delete。 */ Ui::UpdaterDialog *ui; + /** @brief 文件下载器,父对象为本窗口,信号在 UI 线程中更新界面。 */ Downloader * m_pDownloader = nullptr; + /** @brief 本地版本号,用于和远程补丁/全量版本比较。 */ QString m_version; + /** @brief 版本检测网络管理器,懒加载创建,避免未检测时占用网络资源。 */ QNetworkAccessManager *m_pManager{nullptr}; + /** @brief 平台标识,当前保留字段;实际解析使用全局 PLATFORM。 */ QString m_platform; + + /** @brief 远程更新日志,用于展示给用户判断更新内容。 */ QString m_changelog; + + /** @brief 模块名保留字段,用于未来拆分多模块更新。 */ QString m_moduleName; + + /** @brief 增量补丁下载 URL。 */ QString m_patchUrl; + + /** @brief 增量补丁基础 URL,来自 updates.json,重复点击下载时用于重新生成完整地址。 */ + QString m_patchBaseUrl; + + /** @brief 增量补丁版本号。 */ QString m_patchVersion; + + /** @brief 模块版本保留字段,用于未来按模块比较版本。 */ QString m_moduleVersion; + + /** @brief 完整安装包下载 URL。 */ QString m_fullUrl; + + /** @brief 完整安装包基础 URL,来自 updates.json,重复点击下载时用于重新生成完整地址。 */ + QString m_fullBaseUrl; + + /** @brief 完整安装包版本号。 */ QString m_fullVersion; + + /** @brief 远程最后修改日期,通常为 yyyyMMdd 字符串。 */ QString m_lastChangeTime; + + /** @brief 远程当天修改计数,同版本同日期时用于判断是否仍需更新。 */ QString m_modifyCnt; + /** @brief 当前下载目标文件名,不包含目录。 */ QString m_fileName = ""; + /** @brief true 表示本次推荐下载完整包,false 表示推荐下载补丁包。 */ bool m_bDownloadFullExe = false; - uint m_startTime; + /** @brief 下载计时器,用于计算平均速度和剩余时间,避免系统时间变化造成负值。 */ + QElapsedTimer m_downloadTimer; + + /** @brief 最近一次进度采样的计时毫秒,用于计算瞬时速度。 */ + qint64 m_lastProgressMs = 0; + + /** @brief 最近一次进度采样的已收字节数,用于计算瞬时速度。 */ + qint64 m_lastProgressBytes = 0; + /** @brief 当前下载总字节数,未知时为 0。 */ quint64 m_totalSize; + /** @brief 用于取消时重启原程序;无父对象时析构函数负责释放,避免进程对象泄漏。 */ QProcess *m_UpdateProc{nullptr}; };