This commit is contained in:
2026-07-01 03:32:41 +00:00
commit cd3dca042d
45 changed files with 8072 additions and 0 deletions
+41
View File
@@ -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和QtOpenSSL库由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()
+269
View File
@@ -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;
}
+54
View File
@@ -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;
};
+670
View File
@@ -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;
}
+69
View File
@@ -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;
};
+325
View File
@@ -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;
}