1.0.0
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
||||
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
||||
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
||||
# 关闭VS自动Win32设计时预生成
|
||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
||||
# 清除32位Strawberry Perl路径干扰
|
||||
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include")
|
||||
list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib")
|
||||
|
||||
project(Updater)
|
||||
set(SRC
|
||||
main.cpp
|
||||
UpdaterLogic.h
|
||||
UpdaterLogic.cpp
|
||||
UpdateTransaction.h
|
||||
UpdateTransaction.cpp
|
||||
)
|
||||
add_executable(Updater ${SRC})
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
|
||||
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
|
||||
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
|
||||
|
||||
# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递
|
||||
target_link_libraries(Updater PRIVATE
|
||||
Common
|
||||
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
||||
)
|
||||
|
||||
# Qt部署脚本
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET Updater POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} --debug $<TARGET_FILE:Updater>
|
||||
)
|
||||
endif()
|
||||
@@ -0,0 +1,269 @@
|
||||
#include "UpdateTransaction.h"
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QSet>
|
||||
#include <QUuid>
|
||||
|
||||
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& fromVersion,
|
||||
const QString& toVersion, int manifestId)
|
||||
: m_installDir(QDir::cleanPath(installDir)),
|
||||
m_updateDir(QDir(installDir).filePath("update")),
|
||||
m_stateFile(QDir(installDir).filePath("update/upgrade_state.json")),
|
||||
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
|
||||
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
|
||||
{
|
||||
m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId);
|
||||
m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId);
|
||||
}
|
||||
|
||||
bool UpdateTransaction::safeRelativePath(const QString& path)
|
||||
{
|
||||
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
|
||||
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
|
||||
&& !clean.startsWith("../") && !clean.contains(":");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const
|
||||
{
|
||||
if (!QDir().mkpath(QFileInfo(destination).path())) return false;
|
||||
if (QFile::exists(destination) && !QFile::remove(destination)) return false;
|
||||
return QFile::copy(source, destination);
|
||||
}
|
||||
|
||||
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
|
||||
{
|
||||
m_state["transaction_id"] = m_transactionId;
|
||||
m_state["from_version"] = m_fromVersion;
|
||||
m_state["to_version"] = m_toVersion;
|
||||
m_state["status"] = status;
|
||||
m_state["manifest_id"] = m_manifestId;
|
||||
m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
m_state["staging_dir"] = m_stagingDir;
|
||||
m_state["backup_dir"] = m_backupDir;
|
||||
m_state["error_code"] = errorCode;
|
||||
m_state["message"] = message;
|
||||
QJsonArray paths;
|
||||
for (const QString& path : m_changedPaths) paths.append(path);
|
||||
m_state["changed_paths"] = paths;
|
||||
QJsonArray obsoletePaths;
|
||||
for (const QString& path : m_obsoletePaths) obsoletePaths.append(path);
|
||||
m_state["obsolete_paths"] = obsoletePaths;
|
||||
|
||||
QDir().mkpath(m_updateDir);
|
||||
QSaveFile file(m_stateFile);
|
||||
if (!file.open(QIODevice::WriteOnly)) return false;
|
||||
const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented);
|
||||
if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; }
|
||||
return file.commit();
|
||||
}
|
||||
|
||||
bool UpdateTransaction::initialize()
|
||||
{
|
||||
QDir(m_stagingDir).removeRecursively();
|
||||
QDir(m_backupDir).removeRecursively();
|
||||
if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false;
|
||||
m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
return writeState("prepared");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::resumeExisting(QString* errorMessage)
|
||||
{
|
||||
QFile file(m_stateFile);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
if (errorMessage) *errorMessage = "cannot open upgrade_state.json";
|
||||
return false;
|
||||
}
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
file.close();
|
||||
if (!doc.isObject()) {
|
||||
if (errorMessage) *errorMessage = "invalid upgrade_state.json";
|
||||
return false;
|
||||
}
|
||||
m_state = doc.object();
|
||||
const QString expectedToVersion = m_toVersion;
|
||||
const int expectedManifestId = m_manifestId;
|
||||
const QString stateStatus = m_state.value("status").toString();
|
||||
m_transactionId = m_state.value("transaction_id").toString();
|
||||
m_fromVersion = m_state.value("from_version").toString();
|
||||
m_toVersion = m_state.value("to_version").toString();
|
||||
m_manifestId = m_state.value("manifest_id").toInt();
|
||||
if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId
|
||||
|| (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify"
|
||||
&& stateStatus != "rollback_required")) {
|
||||
if (errorMessage) *errorMessage = "upgrade state does not match resume request";
|
||||
return false;
|
||||
}
|
||||
m_stagingDir = m_state.value("staging_dir").toString();
|
||||
m_backupDir = m_state.value("backup_dir").toString();
|
||||
m_changedPaths.clear();
|
||||
m_obsoletePaths.clear();
|
||||
for (const QJsonValue& value : m_state.value("changed_paths").toArray()) {
|
||||
const QString path = value.toString();
|
||||
if (!safeRelativePath(path)) {
|
||||
if (errorMessage) *errorMessage = "unsafe path in upgrade state";
|
||||
return false;
|
||||
}
|
||||
m_changedPaths.append(path);
|
||||
}
|
||||
for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) {
|
||||
const QString path = value.toString();
|
||||
if (!safeRelativePath(path)) {
|
||||
if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state";
|
||||
return false;
|
||||
}
|
||||
m_obsoletePaths.append(path);
|
||||
}
|
||||
if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "incomplete upgrade state";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
|
||||
const QStringList& obsoletePaths)
|
||||
{
|
||||
m_changedPaths.clear();
|
||||
m_obsoletePaths.clear();
|
||||
QSet<QString> changedKeys;
|
||||
for (const QString& path : changedPaths) {
|
||||
if (!safeRelativePath(path)) return false;
|
||||
const QString normalized = QDir::fromNativeSeparators(path);
|
||||
changedKeys.insert(normalized.toCaseFolded());
|
||||
m_changedPaths.append(normalized);
|
||||
}
|
||||
for (const QString& path : obsoletePaths) {
|
||||
if (!safeRelativePath(path)) return false;
|
||||
const QString normalized = QDir::fromNativeSeparators(path);
|
||||
if (changedKeys.contains(normalized.toCaseFolded())) return false;
|
||||
m_obsoletePaths.append(normalized);
|
||||
}
|
||||
return writeState("verified");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::backupCurrentFiles()
|
||||
{
|
||||
if (!writeState("waiting_mainapp_exit")) return false;
|
||||
QStringList paths = m_changedPaths;
|
||||
paths.append(m_obsoletePaths);
|
||||
for (const QString& path : paths) {
|
||||
const QString installed = QDir(m_installDir).filePath(path);
|
||||
if (!QFile::exists(installed)) continue;
|
||||
const QString backup = QDir(m_backupDir).filePath(path);
|
||||
if (!copyOverwrite(installed, backup))
|
||||
return markFailed("backup_failed", path);
|
||||
}
|
||||
return writeState("backed_up");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::installStagedFiles(QString* failedPath)
|
||||
{
|
||||
if (!writeState("replacing")) return false;
|
||||
for (const QString& path : m_changedPaths) {
|
||||
const QString source = QDir(m_stagingDir).filePath(path);
|
||||
const QString destination = QDir(m_installDir).filePath(path);
|
||||
if (!copyOverwrite(source, destination)) {
|
||||
if (failedPath) *failedPath = path;
|
||||
writeState("rollback_required", "replace_failed", path);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return writeState("replaced");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); }
|
||||
|
||||
bool UpdateTransaction::markRollbackRequired(const QString& reason)
|
||||
{ return writeState("rollback_required", "post_install_failed", reason); }
|
||||
|
||||
bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); }
|
||||
|
||||
bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); }
|
||||
|
||||
bool UpdateTransaction::rollback(QString* failedPath)
|
||||
{
|
||||
writeState("rolling_back");
|
||||
bool ok = true;
|
||||
QStringList paths = m_changedPaths;
|
||||
paths.append(m_obsoletePaths);
|
||||
for (const QString& path : paths) {
|
||||
const QString installed = QDir(m_installDir).filePath(path);
|
||||
const QString backup = QDir(m_backupDir).filePath(path);
|
||||
if (QFile::exists(backup)) {
|
||||
if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; }
|
||||
} else if (QFile::exists(installed) && !QFile::remove(installed)) {
|
||||
ok = false; if (failedPath) *failedPath = path;
|
||||
}
|
||||
}
|
||||
writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed",
|
||||
failedPath ? *failedPath : QString());
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool UpdateTransaction::commit()
|
||||
{
|
||||
if (!writeState("committed")) return false;
|
||||
QDir(m_stagingDir).removeRecursively();
|
||||
QDir(m_backupDir).removeRecursively();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message)
|
||||
{ return writeState("failed", errorCode, message); }
|
||||
|
||||
QString UpdateTransaction::transactionId() const { return m_transactionId; }
|
||||
QString UpdateTransaction::fromVersion() const { return m_fromVersion; }
|
||||
QString UpdateTransaction::stagingDir() const { return m_stagingDir; }
|
||||
QString UpdateTransaction::backupDir() const { return m_backupDir; }
|
||||
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
|
||||
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
|
||||
|
||||
bool UpdateTransaction::recoverInterrupted(const QString& installDir, QString* restoredVersion, QString* errorMessage)
|
||||
{
|
||||
const QString stateFile = QDir(installDir).filePath("update/upgrade_state.json");
|
||||
QFile file(stateFile);
|
||||
if (!file.exists()) return true;
|
||||
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
file.close();
|
||||
if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; }
|
||||
QJsonObject state = doc.object();
|
||||
const QString status = state.value("status").toString();
|
||||
if (status == "committed") return true;
|
||||
if (status == "rolled_back") {
|
||||
if (restoredVersion) *restoredVersion = state.value("from_version").toString();
|
||||
return true;
|
||||
}
|
||||
const QString backupDir = state.value("backup_dir").toString();
|
||||
QJsonArray paths = state.value("changed_paths").toArray();
|
||||
for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value);
|
||||
const QString errorCode = state.value("error_code").toString();
|
||||
const bool mayContainNewFiles = status == "replacing" || status == "replaced"
|
||||
|| status == "post_verify" || status == "rollback_required"
|
||||
|| status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed";
|
||||
bool ok = true;
|
||||
for (const QJsonValue& value : paths) {
|
||||
const QString path = value.toString();
|
||||
if (!safeRelativePath(path)) { ok = false; continue; }
|
||||
const QString installed = QDir(installDir).filePath(path);
|
||||
const QString backup = QDir(backupDir).filePath(path);
|
||||
if (QFile::exists(backup)) {
|
||||
QDir().mkpath(QFileInfo(installed).path());
|
||||
if (QFile::exists(installed)) QFile::remove(installed);
|
||||
if (!QFile::copy(backup, installed)) ok = false;
|
||||
} else if (mayContainNewFiles) {
|
||||
if (QFile::exists(installed) && !QFile::remove(installed)) ok = false;
|
||||
}
|
||||
}
|
||||
if (restoredVersion) *restoredVersion = state.value("from_version").toString();
|
||||
state["status"] = ok ? "rolled_back" : "failed";
|
||||
QSaveFile output(stateFile);
|
||||
if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); }
|
||||
if (!ok && errorMessage) *errorMessage = "failed to restore one or more files";
|
||||
return ok;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QJsonObject>
|
||||
|
||||
class UpdateTransaction
|
||||
{
|
||||
public:
|
||||
UpdateTransaction(const QString& installDir, const QString& fromVersion,
|
||||
const QString& toVersion, int manifestId);
|
||||
|
||||
bool initialize();
|
||||
bool resumeExisting(QString* errorMessage = nullptr);
|
||||
bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths);
|
||||
bool backupCurrentFiles();
|
||||
bool installStagedFiles(QString* failedPath = nullptr);
|
||||
bool markAwaitingBootstrap();
|
||||
bool markRollbackRequired(const QString& reason);
|
||||
bool markRolledBack();
|
||||
bool markPostVerify();
|
||||
bool rollback(QString* failedPath = nullptr);
|
||||
bool commit();
|
||||
bool markFailed(const QString& errorCode, const QString& message);
|
||||
|
||||
QString transactionId() const;
|
||||
QString fromVersion() const;
|
||||
QString stagingDir() const;
|
||||
QString backupDir() const;
|
||||
QStringList obsoletePaths() const;
|
||||
QString healthFile() const;
|
||||
|
||||
static bool recoverInterrupted(const QString& installDir,
|
||||
QString* restoredVersion = nullptr,
|
||||
QString* errorMessage = nullptr);
|
||||
|
||||
private:
|
||||
bool writeState(const QString& status, const QString& errorCode = QString(),
|
||||
const QString& message = QString());
|
||||
bool copyOverwrite(const QString& source, const QString& destination) const;
|
||||
static bool safeRelativePath(const QString& path);
|
||||
|
||||
QString m_installDir;
|
||||
QString m_updateDir;
|
||||
QString m_stateFile;
|
||||
QString m_transactionId;
|
||||
QString m_fromVersion;
|
||||
QString m_toVersion;
|
||||
int m_manifestId = 0;
|
||||
QString m_stagingDir;
|
||||
QString m_backupDir;
|
||||
QStringList m_changedPaths;
|
||||
QStringList m_obsoletePaths;
|
||||
QJsonObject m_state;
|
||||
};
|
||||
@@ -0,0 +1,670 @@
|
||||
#include "UpdaterLogic.h"
|
||||
#include <QSet>
|
||||
#include <QDebug>
|
||||
#include <QFileInfo>
|
||||
#include <QFile>
|
||||
#include <QEventLoop>
|
||||
#include <QElapsedTimer>
|
||||
#include <QThread>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkProxy>
|
||||
#include <QJsonArray>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QJsonDocument>
|
||||
#include <QApplication>
|
||||
#include <algorithm>
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/pem.h>
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/bio.h>
|
||||
#include <openssl/err.h>
|
||||
#endif
|
||||
|
||||
UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
|
||||
}
|
||||
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
QString url = m_serverAddr + "/api/v1/update/manifest";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
body["channel"] = channel;
|
||||
body["version"] = targetVer;
|
||||
body["version_id"] = versionId;
|
||||
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "Manifest API returned code:" << code;
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
|
||||
if (code == 200)
|
||||
{
|
||||
if (resp.contains("manifest_text") && resp.contains("manifest"))
|
||||
{
|
||||
m_manifestText = resp["manifest_text"].toString();
|
||||
m_manifest = resp["manifest"].toObject();
|
||||
qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
|
||||
m_fileItems.clear();
|
||||
QJsonArray files = m_manifest.value("files").toArray();
|
||||
for (const QJsonValue& fileItem : files)
|
||||
{
|
||||
QJsonObject fileObj = fileItem.toObject();
|
||||
FileDownloadItem fi;
|
||||
fi.path = fileObj.value("path").toString();
|
||||
fi.sha256 = fileObj.value("sha256").toString();
|
||||
fi.size = fileObj.value("size").toVariant().toLongLong();
|
||||
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL
|
||||
m_fileItems.append(fi);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Manifest response missing fields";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Failed to get manifest";
|
||||
}
|
||||
emit fetchUrlFinished();
|
||||
});
|
||||
}
|
||||
|
||||
bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload);
|
||||
Q_UNUSED(signatureBase64);
|
||||
Q_UNUSED(publicKeyPath);
|
||||
qDebug() << "OpenSSL not available, cannot verify manifest signature";
|
||||
return false;
|
||||
#else
|
||||
QFile keyFile(publicKeyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly))
|
||||
{
|
||||
qDebug() << "Cannot open public key file:" << publicKeyPath;
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray keyData = keyFile.readAll();
|
||||
keyFile.close();
|
||||
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
if (!bio)
|
||||
{
|
||||
qDebug() << "BIO_new_mem_buf failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL);
|
||||
BIO_free(bio);
|
||||
if (!pkey)
|
||||
{
|
||||
qDebug() << "PEM_read_bio_PUBKEY failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
|
||||
if (!mdctx)
|
||||
{
|
||||
EVP_PKEY_free(pkey);
|
||||
qDebug() << "EVP_MD_CTX_new failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ok = false;
|
||||
if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1)
|
||||
{
|
||||
if (EVP_DigestVerifyUpdate(mdctx, payload.constData(), payload.size()) == 1)
|
||||
{
|
||||
if (EVP_DigestVerifyFinal(mdctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1)
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EVP_MD_CTX_free(mdctx);
|
||||
EVP_PKEY_free(pkey);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
qDebug() << "Manifest signature verification failed";
|
||||
}
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
{
|
||||
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
||||
{
|
||||
qDebug() << "No manifest available to verify";
|
||||
return false;
|
||||
}
|
||||
QString signature = m_manifest.value("signature").toString();
|
||||
if (signature.isEmpty())
|
||||
{
|
||||
qDebug() << "Manifest signature empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString path = publicKeyPath;
|
||||
if (!QFile::exists(path))
|
||||
{
|
||||
path = QApplication::applicationDirPath() + "/" + publicKeyPath;
|
||||
}
|
||||
return verifySignature(m_manifestText.toUtf8(), signature, path);
|
||||
}
|
||||
|
||||
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
{
|
||||
if (m_manifest.isEmpty())
|
||||
return false;
|
||||
|
||||
QDir dir(cacheDir);
|
||||
if (!dir.exists() && !dir.mkpath("."))
|
||||
return false;
|
||||
|
||||
QString version = m_manifest.value("version").toString();
|
||||
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
return false;
|
||||
|
||||
QJsonObject wrapper;
|
||||
wrapper["manifest"] = m_manifest;
|
||||
wrapper["manifest_text"] = m_manifestText;
|
||||
|
||||
QJsonDocument doc(wrapper);
|
||||
file.write(doc.toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
qDebug() << "Manifest cached to" << filePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
|
||||
{
|
||||
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
||||
QFile file(filePath);
|
||||
if (!file.exists() || !file.open(QIODevice::ReadOnly))
|
||||
return false;
|
||||
|
||||
QByteArray raw = file.readAll();
|
||||
file.close();
|
||||
QJsonDocument doc = QJsonDocument::fromJson(raw);
|
||||
if (!doc.isObject())
|
||||
return false;
|
||||
|
||||
QJsonObject wrapper = doc.object();
|
||||
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text"))
|
||||
return false;
|
||||
|
||||
m_manifest = wrapper["manifest"].toObject();
|
||||
m_manifestText = wrapper["manifest_text"].toString();
|
||||
qDebug() << "Loaded cached manifest" << version;
|
||||
return true;
|
||||
}
|
||||
|
||||
QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const
|
||||
{
|
||||
QJsonObject copy = manifest;
|
||||
copy.remove("signature");
|
||||
|
||||
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
|
||||
QStringList keys = obj.keys();
|
||||
std::sort(keys.begin(), keys.end(), std::less<QString>());
|
||||
QJsonObject sorted;
|
||||
for (const auto& key : keys)
|
||||
{
|
||||
QJsonValue value = obj.value(key);
|
||||
if (value.isObject())
|
||||
sorted.insert(key, self(self, value.toObject()));
|
||||
else if (value.isArray())
|
||||
{
|
||||
QJsonArray sortedArray;
|
||||
for (const auto& element : value.toArray())
|
||||
{
|
||||
if (element.isObject())
|
||||
sortedArray.append(self(self, element.toObject()));
|
||||
else
|
||||
sortedArray.append(element);
|
||||
}
|
||||
sorted.insert(key, sortedArray);
|
||||
}
|
||||
else
|
||||
{
|
||||
sorted.insert(key, value);
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
};
|
||||
|
||||
QJsonObject sorted = sortObject(sortObject, copy);
|
||||
QJsonDocument doc(sorted);
|
||||
return doc.toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
|
||||
QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest) const
|
||||
{
|
||||
QSet<QString> newPaths;
|
||||
for (const QJsonValue& value : m_manifest.value("files").toArray()) {
|
||||
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
|
||||
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
||||
}
|
||||
|
||||
const QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("launcher.exe"),
|
||||
QStringLiteral("updater.exe"),
|
||||
QStringLiteral("client.ini"),
|
||||
QStringLiteral("config/app_config.json"),
|
||||
QStringLiteral("config/local_state.json"),
|
||||
QStringLiteral("config/client_identity.dat"),
|
||||
QStringLiteral("config/version_policy.dat")
|
||||
};
|
||||
QStringList obsolete;
|
||||
QSet<QString> seen;
|
||||
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
|
||||
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
|
||||
const QString folded = path.toCaseFolded();
|
||||
if (!isSafeRelativePath(path) || protectedPaths.contains(folded)
|
||||
|| newPaths.contains(folded) || seen.contains(folded))
|
||||
continue;
|
||||
seen.insert(folded);
|
||||
obsolete.append(path);
|
||||
}
|
||||
obsolete.sort(Qt::CaseInsensitive);
|
||||
return obsolete;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
|
||||
{
|
||||
if (m_manifest.isEmpty())
|
||||
return false;
|
||||
|
||||
const QJsonArray files = m_manifest.value("files").toArray();
|
||||
for (const auto& item : files)
|
||||
{
|
||||
const QJsonObject fileObject = item.toObject();
|
||||
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
|
||||
const QString expectedSha = fileObject.value("sha256").toString();
|
||||
if (!isSafeRelativePath(path))
|
||||
return false;
|
||||
if (isRuntimeProtectedPath(path)) {
|
||||
qDebug() << "Runtime-protected manifest entry ignored:" << path;
|
||||
continue;
|
||||
}
|
||||
|
||||
QString fullPath = QDir(stagingDir).filePath(path);
|
||||
if (!QFile::exists(fullPath) && !installedDir.isEmpty())
|
||||
fullPath = QDir(installedDir).filePath(path);
|
||||
|
||||
if (!QFile::exists(fullPath))
|
||||
{
|
||||
qDebug() << "Manifest file missing from staging and installation:" << path;
|
||||
return false;
|
||||
}
|
||||
const QString actualSha = calcLocalFileSha256(fullPath);
|
||||
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
|
||||
{
|
||||
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
qDebug() << "Full manifest validation passed using staging plus installed files";
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
body["channel"] = channel;
|
||||
body["version"] = targetVer;
|
||||
body["version_id"] = versionId;
|
||||
|
||||
QJsonArray emptyFiles;
|
||||
body["files"] = emptyFiles;
|
||||
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "Download URL API returned code:" << code;
|
||||
m_fileItems.clear();
|
||||
|
||||
if (code == 200)
|
||||
{
|
||||
QJsonArray fileArr = resp["files"].toArray();
|
||||
for (auto item : fileArr)
|
||||
{
|
||||
QJsonObject obj = item.toObject();
|
||||
FileDownloadItem fi;
|
||||
fi.path = obj["path"].toString();
|
||||
fi.url = obj["url"].toString();
|
||||
fi.sha256 = obj["sha256"].toString();
|
||||
fi.size = obj["size"].toVariant().toLongLong();
|
||||
m_fileItems.append(fi);
|
||||
qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Failed to get download URL";
|
||||
}
|
||||
emit fetchUrlFinished();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const
|
||||
{
|
||||
QFile f(filePath);
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
return "";
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256);
|
||||
while (!f.atEnd())
|
||||
{
|
||||
QByteArray block = f.read(4096);
|
||||
hash.addData(block);
|
||||
}
|
||||
f.close();
|
||||
return hash.result().toHex();
|
||||
}
|
||||
|
||||
bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath,
|
||||
const QString& expectSha256, qint64 expectedSize,
|
||||
const QString& resumePartPath)
|
||||
{
|
||||
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
|
||||
if (!QDir().mkpath(QFileInfo(partPath).path())) return false;
|
||||
if (QFile::exists(savePath)
|
||||
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
||||
return true;
|
||||
QFile::remove(savePath);
|
||||
|
||||
for (int attempt = 0; attempt < 4; ++attempt)
|
||||
{
|
||||
qint64 existingSize = QFileInfo(partPath).size();
|
||||
if (expectedSize >= 0 && existingSize > expectedSize)
|
||||
{
|
||||
QFile::remove(partPath);
|
||||
existingSize = 0;
|
||||
}
|
||||
if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0)
|
||||
{
|
||||
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
||||
{
|
||||
QFile::remove(savePath);
|
||||
return QFile::rename(partPath, savePath);
|
||||
}
|
||||
QFile::remove(partPath);
|
||||
existingSize = 0;
|
||||
}
|
||||
|
||||
QFile partFile(partPath);
|
||||
const QIODevice::OpenMode mode = QIODevice::WriteOnly
|
||||
| (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate);
|
||||
if (!partFile.open(mode))
|
||||
{
|
||||
qDebug() << "Cannot open partial download:" << partPath;
|
||||
return false;
|
||||
}
|
||||
|
||||
QNetworkAccessManager manager;
|
||||
manager.setProxy(QNetworkProxy::NoProxy);
|
||||
QNetworkRequest request(url);
|
||||
request.setTransferTimeout(60000);
|
||||
if (existingSize > 0)
|
||||
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
|
||||
|
||||
QNetworkReply* reply = manager.get(request);
|
||||
QEventLoop loop;
|
||||
bool writeOk = true;
|
||||
QObject::connect(reply, &QNetworkReply::readyRead, [&]() {
|
||||
const QByteArray data = reply->readAll();
|
||||
if (!data.isEmpty()) {
|
||||
if (partFile.write(data) != data.size()) writeOk = false;
|
||||
m_sessionDownloadedBytes += data.size();
|
||||
const qint64 received = qMin(m_downloadTotalBytes,
|
||||
m_downloadCompletedBytes + partFile.size());
|
||||
const double speed = m_downloadTimer.elapsed() > 0
|
||||
? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0;
|
||||
emit downloadProgress(received, m_downloadTotalBytes, m_currentDownloadPath, speed);
|
||||
}
|
||||
});
|
||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
const QByteArray remaining = reply->readAll();
|
||||
if (!remaining.isEmpty() && partFile.write(remaining) != remaining.size()) writeOk = false;
|
||||
partFile.flush();
|
||||
partFile.close();
|
||||
|
||||
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const bool networkOk = reply->error() == QNetworkReply::NoError;
|
||||
const QString networkError = reply->errorString();
|
||||
reply->deleteLater();
|
||||
|
||||
if (existingSize > 0 && httpStatus == 200)
|
||||
{
|
||||
// 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。
|
||||
qDebug() << "Server ignored Range; restart full download:" << savePath;
|
||||
QFile::remove(partPath);
|
||||
}
|
||||
else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206))
|
||||
{
|
||||
const qint64 actualSize = QFileInfo(partPath).size();
|
||||
if ((expectedSize < 0 || actualSize == expectedSize)
|
||||
&& calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
||||
{
|
||||
QFile::remove(savePath);
|
||||
if (QFile::rename(partPath, savePath))
|
||||
{
|
||||
qDebug() << "Download completed/resumed & sha pass:" << savePath;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (expectedSize >= 0 && actualSize >= expectedSize)
|
||||
{
|
||||
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
|
||||
QFile::remove(partPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
|
||||
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
|
||||
}
|
||||
|
||||
if (attempt < 3)
|
||||
{
|
||||
const unsigned long delayMs = static_cast<unsigned long>(500 * (1 << attempt));
|
||||
QElapsedTimer delay;
|
||||
delay.start();
|
||||
while (delay.elapsed() < static_cast<qint64>(delayMs))
|
||||
{
|
||||
QApplication::processEvents();
|
||||
QThread::msleep(50);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
||||
{
|
||||
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
static const QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("client.ini"),
|
||||
QStringLiteral("config/app_config.json"),
|
||||
QStringLiteral("config/local_state.json"),
|
||||
QStringLiteral("config/client_identity.dat"),
|
||||
QStringLiteral("config/version_policy.dat")
|
||||
};
|
||||
return protectedPaths.contains(normalized);
|
||||
}
|
||||
|
||||
bool UpdaterLogic::isSafeRelativePath(const QString& path) const
|
||||
{
|
||||
const QString normalized = QDir::fromNativeSeparators(path);
|
||||
const QString clean = QDir::cleanPath(normalized);
|
||||
return !clean.isEmpty()
|
||||
&& !QDir::isAbsolutePath(clean)
|
||||
&& clean != ".."
|
||||
&& !clean.startsWith("../")
|
||||
&& !clean.contains(":");
|
||||
}
|
||||
|
||||
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
||||
const QStringList& obsoletePaths) const
|
||||
{
|
||||
qint64 required = 0;
|
||||
const QString cacheDir = QDir(targetDir).filePath("update/download_cache");
|
||||
for (const auto& item : m_fileItems) {
|
||||
const QString relativePath = QDir::fromNativeSeparators(item.path);
|
||||
if (isRuntimeProtectedPath(relativePath)) continue;
|
||||
const QString installed = QDir(targetDir).filePath(relativePath);
|
||||
if (QFile::exists(installed)
|
||||
&& calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) == 0)
|
||||
continue;
|
||||
|
||||
const qint64 partialSize = QFileInfo(QDir(cacheDir).filePath(
|
||||
item.sha256.toLower() + ".part")).size();
|
||||
required += qMax<qint64>(0, item.size - qMin(item.size, partialSize));
|
||||
if (QFile::exists(installed)) required += QFileInfo(installed).size();
|
||||
}
|
||||
for (const QString& path : obsoletePaths) {
|
||||
const QString installed = QDir(targetDir).filePath(path);
|
||||
if (QFile::exists(installed)) required += QFileInfo(installed).size();
|
||||
}
|
||||
const qint64 reserve = qMax<qint64>(128LL * 1024 * 1024, required / 10);
|
||||
return required + reserve;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
|
||||
{
|
||||
if (m_fileItems.isEmpty())
|
||||
{
|
||||
m_downloadAllOk = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir stagingDir(tempDir);
|
||||
if (!FileHelper::createDir(tempDir))
|
||||
return false;
|
||||
const QString resumeCacheDir = QDir(targetDir).filePath("update/download_cache");
|
||||
if (!FileHelper::createDir(resumeCacheDir))
|
||||
return false;
|
||||
QSet<QString> activePartialNames;
|
||||
for (const auto& item : m_fileItems)
|
||||
activePartialNames.insert(item.sha256.toLower() + ".part");
|
||||
QDir cacheDir(resumeCacheDir);
|
||||
for (const QString& fileName : cacheDir.entryList(QStringList() << "*.part", QDir::Files)) {
|
||||
if (!activePartialNames.contains(fileName.toLower()))
|
||||
cacheDir.remove(fileName);
|
||||
}
|
||||
|
||||
m_downloadTotalBytes = 0;
|
||||
m_downloadCompletedBytes = 0;
|
||||
m_sessionDownloadedBytes = 0;
|
||||
for (const auto& item : m_fileItems) {
|
||||
const QString itemPath = QDir::fromNativeSeparators(item.path);
|
||||
if (isRuntimeProtectedPath(itemPath)) continue;
|
||||
const QString installed = QDir(targetDir).filePath(itemPath);
|
||||
if (!QFile::exists(installed)
|
||||
|| calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) != 0)
|
||||
m_downloadTotalBytes += item.size;
|
||||
}
|
||||
m_downloadTimer.restart();
|
||||
emit downloadProgress(0, m_downloadTotalBytes, QString(), 0.0);
|
||||
|
||||
for (const auto& fi : m_fileItems)
|
||||
{
|
||||
if (!isSafeRelativePath(fi.path))
|
||||
{
|
||||
qDebug() << "Unsafe relative path in manifest:" << fi.path;
|
||||
m_downloadAllOk = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString relativePath = QDir::fromNativeSeparators(fi.path);
|
||||
if (isRuntimeProtectedPath(relativePath)) {
|
||||
qDebug() << "Runtime-protected file skipped:" << relativePath;
|
||||
continue;
|
||||
}
|
||||
const QString installedFile = QDir(targetDir).filePath(relativePath);
|
||||
if (QFile::exists(installedFile)
|
||||
&& calcLocalFileSha256(installedFile).compare(fi.sha256, Qt::CaseInsensitive) == 0)
|
||||
{
|
||||
qDebug() << "Unchanged file, skip download:" << relativePath;
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString tempFile = QDir(tempDir).filePath(relativePath);
|
||||
const QString parentDir = QFileInfo(tempFile).path();
|
||||
if (!QDir().mkpath(parentDir))
|
||||
{
|
||||
qDebug() << "Cannot create staging subdirectory:" << parentDir;
|
||||
m_downloadAllOk = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part");
|
||||
m_currentDownloadPath = relativePath;
|
||||
const qint64 resumedBytes = qMin(fi.size, QFileInfo(resumePartPath).size());
|
||||
const double speed = m_downloadTimer.elapsed() > 0
|
||||
? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0;
|
||||
emit downloadProgress(m_downloadCompletedBytes + resumedBytes,
|
||||
m_downloadTotalBytes, m_currentDownloadPath, speed);
|
||||
if (!downloadSingleFile(fi.url, tempFile, fi.sha256, fi.size, resumePartPath))
|
||||
{
|
||||
m_downloadAllOk = false;
|
||||
return false;
|
||||
}
|
||||
m_downloadCompletedBytes += fi.size;
|
||||
emit downloadProgress(m_downloadCompletedBytes, m_downloadTotalBytes,
|
||||
m_currentDownloadPath, speed);
|
||||
}
|
||||
|
||||
m_downloadAllOk = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdaterLogic::reportResult(const QString& deviceId,
|
||||
const QString& fromVer,
|
||||
const QString& toVer,
|
||||
bool success)
|
||||
{
|
||||
QString url = m_serverAddr + "/api/v1/update/report";
|
||||
|
||||
QJsonObject body;
|
||||
body["device_id"] = deviceId;
|
||||
body["from_version"] = fromVer;
|
||||
body["to_version"] = toVer;
|
||||
|
||||
if (success)
|
||||
body["result"] = "success";
|
||||
else
|
||||
body["result"] = "fail";
|
||||
|
||||
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
|
||||
{
|
||||
Q_UNUSED(resp);
|
||||
qDebug() << "Update result report returned code:" << code;
|
||||
});
|
||||
}
|
||||
|
||||
QList<FileDownloadItem> UpdaterLogic::getFileList() const
|
||||
{
|
||||
return m_fileItems;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#pragma once
|
||||
#include <QObject>
|
||||
#include "HttpHelper.h"
|
||||
#include "FileHelper.h"
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QStringList>
|
||||
#include <QNetworkReply>
|
||||
#include <QMap>
|
||||
#include <QElapsedTimer>
|
||||
|
||||
struct FileDownloadItem
|
||||
{
|
||||
QString path;
|
||||
QString url;
|
||||
QString sha256;
|
||||
qint64 size;
|
||||
};
|
||||
|
||||
class UpdaterLogic : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit UpdaterLogic(QObject* parent = nullptr);
|
||||
|
||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
||||
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
|
||||
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
||||
bool saveManifestCache(const QString& cacheDir) const;
|
||||
bool loadManifestCache(const QString& cacheDir, const QString& version);
|
||||
|
||||
void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
||||
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
|
||||
bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
|
||||
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
|
||||
QString calcLocalFileSha256(const QString& filePath) const;
|
||||
|
||||
QList<FileDownloadItem> getFileList() const;
|
||||
QJsonObject getManifest() const { return m_manifest; }
|
||||
QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const;
|
||||
QString getManifestText() const { return m_manifestText; }
|
||||
bool allDownloadSuccess() const { return m_downloadAllOk; }
|
||||
|
||||
signals:
|
||||
void fetchUrlFinished();
|
||||
void downloadProgress(qint64 receivedBytes, qint64 totalBytes,
|
||||
const QString& currentPath, double bytesPerSecond);
|
||||
|
||||
private:
|
||||
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
|
||||
qint64 expectedSize, const QString& resumePartPath);
|
||||
bool isSafeRelativePath(const QString& path) const;
|
||||
bool isRuntimeProtectedPath(const QString& path) const;
|
||||
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
|
||||
bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const;
|
||||
|
||||
HttpHelper m_http;
|
||||
QString m_serverAddr;
|
||||
QJsonObject m_manifest;
|
||||
QString m_manifestText;
|
||||
QList<FileDownloadItem> m_fileItems;
|
||||
bool m_downloadAllOk = false;
|
||||
qint64 m_downloadTotalBytes = 0;
|
||||
qint64 m_downloadCompletedBytes = 0;
|
||||
qint64 m_sessionDownloadedBytes = 0;
|
||||
QString m_currentDownloadPath;
|
||||
QElapsedTimer m_downloadTimer;
|
||||
};
|
||||
@@ -0,0 +1,325 @@
|
||||
#include <windows.h>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QProgressDialog>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QTextCodec>
|
||||
#include <QThread>
|
||||
#include "UpdaterLogic.h"
|
||||
#include "UpdateTransaction.h"
|
||||
#include "FileHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
SetConsoleOutputCP(65001);
|
||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("Marsco Updater");
|
||||
|
||||
if (argc < 5) {
|
||||
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少应用、渠道或目标版本参数,请从 Launcher 启动。");
|
||||
return -1;
|
||||
}
|
||||
|
||||
const QString appId = argv[1];
|
||||
const QString channel = argv[2];
|
||||
const QString targetVersion = argv[3];
|
||||
const int targetVersionId = QString(argv[4]).toInt();
|
||||
QString bootstrapResult;
|
||||
for (int i = 5; i < argc; ++i) {
|
||||
const QString arg = argv[i];
|
||||
if (arg.startsWith("--bootstrap-resume="))
|
||||
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
||||
}
|
||||
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
||||
const QString targetDir = QApplication::applicationDirPath();
|
||||
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
QString fromVersion = config.getValue("App", "current_version");
|
||||
QString deviceId = config.getValue("Update", "device_id");
|
||||
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
||||
|
||||
if (!resumingFromBootstrap) {
|
||||
QString restoredVersion;
|
||||
QString recoveryError;
|
||||
if (!UpdateTransaction::recoverInterrupted(targetDir, &restoredVersion, &recoveryError)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败",
|
||||
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
||||
return -1;
|
||||
}
|
||||
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
|
||||
if (!config.setValue("App", "current_version", restoredVersion)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。");
|
||||
return -1;
|
||||
}
|
||||
fromVersion = restoredVersion;
|
||||
}
|
||||
}
|
||||
|
||||
QProgressDialog progress("正在准备更新...", QString(), 0, 100);
|
||||
progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion));
|
||||
progress.setCancelButton(nullptr);
|
||||
progress.setWindowModality(Qt::ApplicationModal);
|
||||
progress.setMinimumDuration(0);
|
||||
progress.setAutoClose(false);
|
||||
progress.setValue(2);
|
||||
progress.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
UpdaterLogic logic;
|
||||
UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId);
|
||||
const auto setProgress = [&](int value, const QString& message) {
|
||||
progress.setValue(value);
|
||||
progress.setLabelText(message);
|
||||
QApplication::processEvents();
|
||||
};
|
||||
const auto formatBytes = [](qint64 bytes) {
|
||||
const double value = static_cast<double>(bytes);
|
||||
if (bytes >= 1024LL * 1024 * 1024)
|
||||
return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB";
|
||||
if (bytes >= 1024LL * 1024)
|
||||
return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB";
|
||||
if (bytes >= 1024)
|
||||
return QString::number(value / 1024.0, 'f', 1) + " KB";
|
||||
return QString::number(bytes) + " B";
|
||||
};
|
||||
QObject::connect(&logic, &UpdaterLogic::downloadProgress,
|
||||
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
|
||||
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
|
||||
const QString fileText = path.isEmpty() ? QString("正在准备下载...")
|
||||
: QString("正在下载:%1").arg(path);
|
||||
progress.setValue(value);
|
||||
progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
|
||||
.arg(fileText, formatBytes(received), formatBytes(total),
|
||||
formatBytes(static_cast<qint64>(bytesPerSecond))));
|
||||
QApplication::processEvents();
|
||||
});
|
||||
const auto fail = [&](const QString& title, const QString& message,
|
||||
const QString& errorCode = QString("update_failed")) {
|
||||
transaction.markFailed(errorCode, message);
|
||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, title, message);
|
||||
return -1;
|
||||
};
|
||||
const QString mainAppPath = QDir(targetDir).filePath("MainApp.exe");
|
||||
const QString updaterPath = QDir(targetDir).filePath("Updater.exe");
|
||||
const QString bootstrapPath = QDir(targetDir).filePath("Bootstrap.exe");
|
||||
const QString launchToken = config.getValue("App", "launch_token");
|
||||
const auto launchMainApp = [&](const QString& healthFile = QString()) {
|
||||
QStringList args{QString("--launcher-token=%1").arg(launchToken)};
|
||||
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
|
||||
return QProcess::startDetached(mainAppPath, args);
|
||||
};
|
||||
const auto bootstrapPlanFile = [&]() {
|
||||
return QDir(targetDir).filePath("update/bootstrap_plan_" + transaction.transactionId() + ".txt");
|
||||
};
|
||||
const auto launchBootstrap = [&](const QString& mode) {
|
||||
const QStringList args{
|
||||
bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath,
|
||||
QString::number(QCoreApplication::applicationPid()), appId, channel,
|
||||
targetVersion, QString::number(targetVersionId), mode
|
||||
};
|
||||
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
|
||||
};
|
||||
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
||||
FileHelper::killProcess("MainApp.exe");
|
||||
transaction.markRollbackRequired(reason);
|
||||
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
||||
QApplication::processEvents();
|
||||
if (launchBootstrap("rollback")) {
|
||||
progress.close();
|
||||
return 0;
|
||||
}
|
||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, title,
|
||||
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
||||
return -1;
|
||||
};
|
||||
|
||||
if (resumingFromBootstrap) {
|
||||
QString resumeError;
|
||||
if (!transaction.resumeExisting(&resumeError)) {
|
||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "事务续办失败",
|
||||
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
||||
return -1;
|
||||
}
|
||||
fromVersion = transaction.fromVersion();
|
||||
if (bootstrapResult == "rolledback") {
|
||||
const bool stateOk = config.setValue("App", "current_version", fromVersion);
|
||||
transaction.markRolledBack();
|
||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||
if (stateOk) launchMainApp();
|
||||
progress.close();
|
||||
QMessageBox::warning(nullptr, "更新已回滚",
|
||||
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。"
|
||||
: "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。");
|
||||
return stateOk ? 0 : -1;
|
||||
}
|
||||
if (bootstrapResult != "success") {
|
||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "自动回滚失败",
|
||||
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
||||
return -1;
|
||||
}
|
||||
} else if (!transaction.initialize()) {
|
||||
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
|
||||
}
|
||||
|
||||
setProgress(resumingFromBootstrap ? 72 : 10, "正在获取并验证版本清单...");
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
||||
if (!logic.verifyManifestSignature()) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
||||
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
||||
}
|
||||
const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache");
|
||||
QStringList obsoletePaths;
|
||||
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
||||
UpdaterLogic oldManifestLogic;
|
||||
if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion)
|
||||
&& oldManifestLogic.getManifest().value("version").toString() == fromVersion
|
||||
&& oldManifestLogic.verifyManifestSignature()) {
|
||||
obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest());
|
||||
qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths;
|
||||
} else {
|
||||
qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety";
|
||||
}
|
||||
}
|
||||
if (!logic.saveManifestCache(manifestCacheDir)) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。");
|
||||
return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed");
|
||||
}
|
||||
|
||||
if (!resumingFromBootstrap) {
|
||||
setProgress(25, "正在获取安全下载地址...");
|
||||
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||
if (logic.getFileList().isEmpty())
|
||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||
|
||||
QStorageInfo storage(targetDir);
|
||||
storage.refresh();
|
||||
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
||||
const qint64 availableBytes = storage.bytesAvailable();
|
||||
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
|
||||
return fail("磁盘空间不足",
|
||||
QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n"
|
||||
"所需空间已包含下载文件、旧版本备份和安全余量。")
|
||||
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
|
||||
"disk_space_insufficient");
|
||||
}
|
||||
|
||||
const QString stagingDir = transaction.stagingDir();
|
||||
setProgress(30, QString("正在下载并校验 %1 个版本文件...").arg(logic.getFileList().size()));
|
||||
if (!logic.downloadAllFiles(stagingDir, targetDir))
|
||||
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
|
||||
|
||||
setProgress(58, "正在校验完整版本文件...");
|
||||
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
||||
return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed");
|
||||
|
||||
QStringList changedPaths;
|
||||
QDir stagingRoot(stagingDir);
|
||||
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
|
||||
while (stagingFiles.hasNext())
|
||||
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
|
||||
for (const QString& path : changedPaths) {
|
||||
if (path.compare("Bootstrap.exe", Qt::CaseInsensitive) == 0)
|
||||
return fail("Bootstrap 无法自更新", "本次版本包含新的 Bootstrap.exe。请使用安装包升级 Bootstrap,再重新发布业务版本。", "bootstrap_self_update_blocked");
|
||||
}
|
||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||
|
||||
setProgress(66, "正在关闭主程序...");
|
||||
if (!FileHelper::killProcess("MainApp.exe"))
|
||||
return fail("无法关闭主程序", "MainApp.exe 仍在运行,请手动关闭后重试。", "mainapp_close_failed");
|
||||
|
||||
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
|
||||
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
||||
if (!transaction.backupCurrentFiles())
|
||||
return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "backup_failed");
|
||||
|
||||
const QString planFile = bootstrapPlanFile();
|
||||
QSaveFile plan(planFile);
|
||||
if (!plan.open(QIODevice::WriteOnly))
|
||||
return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed");
|
||||
const auto writePlanItem = [&](char operation, const QString& path) {
|
||||
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
|
||||
return plan.write(line) == line.size();
|
||||
};
|
||||
for (const QString& path : changedPaths) {
|
||||
if (!writePlanItem('C', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
for (const QString& path : obsoletePaths) {
|
||||
if (!writePlanItem('D', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
if (!plan.commit() || !transaction.markAwaitingBootstrap())
|
||||
return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed");
|
||||
|
||||
setProgress(78, "正在将安装工作移交给 Bootstrap...");
|
||||
if (!launchBootstrap("install"))
|
||||
return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed");
|
||||
progress.close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
setProgress(82, "正在校验 Bootstrap 安装结果...");
|
||||
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
|
||||
return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。");
|
||||
for (const QString& path : transaction.obsoletePaths()) {
|
||||
if (QFile::exists(QDir(targetDir).filePath(path)))
|
||||
return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path));
|
||||
}
|
||||
|
||||
setProgress(89, "正在保存新版本状态...");
|
||||
if (!config.setValue("App", "current_version", targetVersion)
|
||||
|| config.getValue("App", "current_version") != targetVersion)
|
||||
return delegateRollback("状态保存失败", "无法保存当前版本号。");
|
||||
|
||||
const QString healthFile = transaction.healthFile();
|
||||
QFile::remove(healthFile);
|
||||
setProgress(94, "正在启动新版本并等待健康确认...");
|
||||
if (!launchMainApp(healthFile))
|
||||
return delegateRollback("启动失败", "MainApp.exe 无法启动。");
|
||||
|
||||
QElapsedTimer healthTimer;
|
||||
healthTimer.start();
|
||||
while (healthTimer.elapsed() < 15000 && !QFile::exists(healthFile)) {
|
||||
QApplication::processEvents();
|
||||
QThread::msleep(100);
|
||||
}
|
||||
if (!QFile::exists(healthFile))
|
||||
return delegateRollback("启动确认失败", "新版本在 15 秒内没有完成启动健康确认。");
|
||||
|
||||
setProgress(99, "正在提交更新事务...");
|
||||
if (!transaction.commit())
|
||||
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。");
|
||||
|
||||
QFile::remove(healthFile);
|
||||
QFile::remove(bootstrapPlanFile());
|
||||
logic.reportResult(deviceId, fromVersion, targetVersion, true);
|
||||
progress.setValue(100);
|
||||
progress.close();
|
||||
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user