feat(hardening): 内嵌产品配置 + 系统级状态 + 全量 i18n + 依赖治理 + 资料清理
update-client-hardening 四工作流合入(构建/静态检查已过): - 配置:不可变产品策略经 UpdateClientResources.cmake 归一化校验后 内嵌 qrc(URL/相对路径/主程序必须落 install_root/版本/UUID 格式, CMake override 写入前复验);可变机器状态迁系统级原生存储; 运行目录不再落可编辑 app_config.json。 - i18n:生产源全英文(no-Han 检查 41/41),中文进 translations/ zh_CN 目录;翻译加载收敛 Common/TranslationHelper 唯一入口; Bootstrap 补资源文件。 - 依赖:Qt/OpenSSL/架构/Perl 机器路径改 imported targets + UpdateClientDependencies.cmake,3rdparty 由父工程契约供给。 - 清理:旧 README/SDK README/3 个重复 PowerShell 打包脚本/PDF 提取 文本删除,canonical README + 5 份结构化英文文档替代;Launcher requireAdministrator manifest;统一 MSVC /utf-8 删运行时编码设置。
This commit is contained in:
+15
-31
@@ -1,41 +1,25 @@
|
||||
# 强制所有编译、设计时生成均使用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
|
||||
set(_updater_sources
|
||||
main.cpp
|
||||
UpdaterLogic.h
|
||||
UpdaterLogic.cpp
|
||||
UpdateTransaction.h
|
||||
UpdateTransaction.cpp
|
||||
)
|
||||
add_executable(Updater ${SRC})
|
||||
|
||||
add_executable(Updater ${_updater_sources})
|
||||
target_compile_features(Updater PRIVATE cxx_std_17)
|
||||
target_link_libraries(Updater
|
||||
PRIVATE
|
||||
Common
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
update_client_link_embedded_resources(Updater)
|
||||
update_client_deploy_openssl_runtime(Updater)
|
||||
update_client_enable_qt_deployment(Updater)
|
||||
|
||||
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} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
|
||||
)
|
||||
endif()
|
||||
|
||||
+311
-61
@@ -14,7 +14,10 @@
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QUrl>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
@@ -24,6 +27,41 @@
|
||||
#include <openssl/err.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
bool jsonNonNegativeInteger(const QJsonValue& value, qint64* result)
|
||||
{
|
||||
constexpr double maxExactJsonInteger = 9007199254740991.0;
|
||||
if (!value.isDouble())
|
||||
return false;
|
||||
|
||||
const double number = value.toDouble();
|
||||
if (!std::isfinite(number) || number < 0.0 || number > maxExactJsonInteger
|
||||
|| std::floor(number) != number)
|
||||
return false;
|
||||
|
||||
if (result)
|
||||
*result = static_cast<qint64>(number);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isSha256(const QString& value)
|
||||
{
|
||||
if (value.size() != 64)
|
||||
return false;
|
||||
|
||||
for (const QChar ch : value)
|
||||
{
|
||||
const ushort code = ch.unicode();
|
||||
if (!((code >= '0' && code <= '9')
|
||||
|| (code >= 'a' && code <= 'f')
|
||||
|| (code >= 'A' && code <= 'F')))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
@@ -32,6 +70,11 @@ UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_fileItems.clear();
|
||||
m_manifestSignatureVerified = false;
|
||||
|
||||
QString url = m_serverAddr + "/api/v1/update/manifest";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
@@ -39,35 +82,64 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
|
||||
body["version"] = targetVer;
|
||||
body["version_id"] = versionId;
|
||||
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "Manifest API returned code:" << code;
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_fileItems.clear();
|
||||
m_manifestSignatureVerified = false;
|
||||
|
||||
if (code == 200)
|
||||
{
|
||||
if (resp.contains("manifest_text") && resp.contains("manifest"))
|
||||
const QJsonValue manifestTextValue = resp.value("manifest_text");
|
||||
const QJsonValue responseManifestValue = resp.value("manifest");
|
||||
if (!manifestTextValue.isString() || !responseManifestValue.isObject())
|
||||
{
|
||||
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);
|
||||
}
|
||||
qDebug() << "Manifest response fields have invalid types";
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Manifest response missing fields";
|
||||
const QString manifestText = manifestTextValue.toString();
|
||||
const QJsonObject responseManifest = responseManifestValue.toObject();
|
||||
const QJsonValue signatureValue = responseManifest.value("signature");
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument manifestDocument =
|
||||
QJsonDocument::fromJson(manifestText.toUtf8(), &parseError);
|
||||
|
||||
if (manifestText.isEmpty() || !signatureValue.isString()
|
||||
|| signatureValue.toString().trimmed().isEmpty()
|
||||
|| parseError.error != QJsonParseError::NoError
|
||||
|| !manifestDocument.isObject())
|
||||
{
|
||||
qDebug() << "Manifest response JSON or signature field is invalid";
|
||||
}
|
||||
else
|
||||
{
|
||||
QJsonObject trustedManifest = manifestDocument.object();
|
||||
QList<FileDownloadItem> trustedFiles;
|
||||
if (trustedManifest.contains("signature"))
|
||||
{
|
||||
qDebug() << "Signed manifest text unexpectedly contains a signature field";
|
||||
}
|
||||
else
|
||||
{
|
||||
trustedManifest.insert("signature", signatureValue.toString());
|
||||
if (validateOnlineManifest(trustedManifest, appId, channel,
|
||||
targetVer, versionId, &trustedFiles))
|
||||
{
|
||||
m_manifestText = manifestText;
|
||||
m_manifest = trustedManifest;
|
||||
m_fileItems = trustedFiles;
|
||||
qDebug() << "Received trusted manifest version:"
|
||||
<< m_manifest.value("version").toString();
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Manifest fields or request identity are invalid";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -78,6 +150,76 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
|
||||
});
|
||||
}
|
||||
|
||||
bool UpdaterLogic::validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
|
||||
const QString& channel, const QString& targetVer,
|
||||
int versionId, QList<FileDownloadItem>* fileItems) const
|
||||
{
|
||||
if (!fileItems)
|
||||
return false;
|
||||
fileItems->clear();
|
||||
|
||||
const QJsonValue appValue = manifest.value("app_id");
|
||||
const QJsonValue channelValue = manifest.value("channel");
|
||||
const QJsonValue versionValue = manifest.value("version");
|
||||
const QJsonValue sequenceValue = manifest.value("manifest_seq");
|
||||
const QJsonValue filesValue = manifest.value("files");
|
||||
const QJsonValue signatureValue = manifest.value("signature");
|
||||
qint64 manifestSequence = -1;
|
||||
if (!appValue.isString() || appValue.toString().isEmpty()
|
||||
|| !channelValue.isString() || channelValue.toString().isEmpty()
|
||||
|| !versionValue.isString() || versionValue.toString().isEmpty()
|
||||
|| !jsonNonNegativeInteger(sequenceValue, &manifestSequence)
|
||||
|| !filesValue.isArray()
|
||||
|| !signatureValue.isString() || signatureValue.toString().trimmed().isEmpty()
|
||||
|| appValue.toString() != appId
|
||||
|| channelValue.toString() != channel
|
||||
|| versionValue.toString() != targetVer
|
||||
|| manifestSequence != static_cast<qint64>(versionId))
|
||||
return false;
|
||||
|
||||
QSet<QString> pathKeys;
|
||||
const QJsonArray files = filesValue.toArray();
|
||||
fileItems->reserve(files.size());
|
||||
for (const QJsonValue& value : files)
|
||||
{
|
||||
if (!value.isObject())
|
||||
{
|
||||
fileItems->clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject file = value.toObject();
|
||||
const QJsonValue pathValue = file.value("path");
|
||||
const QJsonValue shaValue = file.value("sha256");
|
||||
const QJsonValue sizeValue = file.value("size");
|
||||
qint64 size = -1;
|
||||
if (!pathValue.isString() || pathValue.toString().isEmpty()
|
||||
|| !shaValue.isString() || !isSha256(shaValue.toString())
|
||||
|| !jsonNonNegativeInteger(sizeValue, &size))
|
||||
{
|
||||
fileItems->clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString path = pathValue.toString();
|
||||
const QString normalizedPath = QDir::cleanPath(QDir::fromNativeSeparators(path));
|
||||
const QString pathKey = normalizedPath.toCaseFolded();
|
||||
if (!isSafeRelativePath(path) || pathKeys.contains(pathKey))
|
||||
{
|
||||
fileItems->clear();
|
||||
return false;
|
||||
}
|
||||
pathKeys.insert(pathKey);
|
||||
|
||||
FileDownloadItem item;
|
||||
item.path = path;
|
||||
item.sha256 = shaValue.toString();
|
||||
item.size = size;
|
||||
fileItems->append(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
@@ -146,6 +288,7 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
||||
|
||||
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
{
|
||||
m_manifestSignatureVerified = false;
|
||||
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
||||
{
|
||||
qDebug() << "No manifest available to verify";
|
||||
@@ -158,17 +301,13 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
return false;
|
||||
}
|
||||
|
||||
QString path = publicKeyPath;
|
||||
if (!QFile::exists(path))
|
||||
{
|
||||
path = QApplication::applicationDirPath() + "/" + publicKeyPath;
|
||||
}
|
||||
return verifySignature(m_manifestText.toUtf8(), signature, path);
|
||||
m_manifestSignatureVerified = verifySignature(m_manifestText.toUtf8(), signature, publicKeyPath);
|
||||
return m_manifestSignatureVerified;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
{
|
||||
if (m_manifest.isEmpty())
|
||||
if (m_manifest.isEmpty() || !m_manifestSignatureVerified)
|
||||
return false;
|
||||
|
||||
QDir dir(cacheDir);
|
||||
@@ -194,6 +333,11 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
|
||||
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
|
||||
{
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_fileItems.clear();
|
||||
m_manifestSignatureVerified = false;
|
||||
|
||||
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
||||
QFile file(filePath);
|
||||
if (!file.exists() || !file.open(QIODevice::ReadOnly))
|
||||
@@ -201,16 +345,37 @@ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& ver
|
||||
|
||||
QByteArray raw = file.readAll();
|
||||
file.close();
|
||||
QJsonDocument doc = QJsonDocument::fromJson(raw);
|
||||
if (!doc.isObject())
|
||||
QJsonParseError wrapperError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &wrapperError);
|
||||
if (wrapperError.error != QJsonParseError::NoError || !doc.isObject())
|
||||
return false;
|
||||
|
||||
QJsonObject wrapper = doc.object();
|
||||
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text"))
|
||||
const QJsonObject wrapper = doc.object();
|
||||
const QJsonValue manifestTextValue = wrapper.value("manifest_text");
|
||||
const QJsonValue cachedManifestValue = wrapper.value("manifest");
|
||||
if (!manifestTextValue.isString() || manifestTextValue.toString().isEmpty()
|
||||
|| !cachedManifestValue.isObject())
|
||||
return false;
|
||||
|
||||
m_manifest = wrapper["manifest"].toObject();
|
||||
m_manifestText = wrapper["manifest_text"].toString();
|
||||
const QJsonValue signatureValue = cachedManifestValue.toObject().value("signature");
|
||||
QJsonParseError manifestError;
|
||||
const QString manifestText = manifestTextValue.toString();
|
||||
const QJsonDocument manifestDocument =
|
||||
QJsonDocument::fromJson(manifestText.toUtf8(), &manifestError);
|
||||
if (!signatureValue.isString() || signatureValue.toString().trimmed().isEmpty()
|
||||
|| manifestError.error != QJsonParseError::NoError
|
||||
|| !manifestDocument.isObject())
|
||||
return false;
|
||||
|
||||
QJsonObject trustedManifest = manifestDocument.object();
|
||||
const QJsonValue versionValue = trustedManifest.value("version");
|
||||
if (trustedManifest.contains("signature") || !versionValue.isString()
|
||||
|| versionValue.toString() != version)
|
||||
return false;
|
||||
|
||||
trustedManifest.insert("signature", signatureValue.toString());
|
||||
m_manifest = trustedManifest;
|
||||
m_manifestText = manifestText;
|
||||
qDebug() << "Loaded cached manifest" << version;
|
||||
return true;
|
||||
}
|
||||
@@ -341,50 +506,66 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
|
||||
{
|
||||
m_offlineError.clear();
|
||||
QFile package(packagePath);
|
||||
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; }
|
||||
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; }
|
||||
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot open the offline update package."); return false; }
|
||||
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package format identifier is invalid."); return false; }
|
||||
const QByteArray lengthBytes = package.read(8);
|
||||
if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; }
|
||||
if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete."); return false; }
|
||||
quint64 headerSize = 0;
|
||||
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
|
||||
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; }
|
||||
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header length is invalid."); return false; }
|
||||
QJsonParseError error;
|
||||
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
|
||||
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; }
|
||||
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header contains invalid JSON."); return false; }
|
||||
const QJsonObject wrapper = wrapperDoc.object();
|
||||
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
|
||||
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem";
|
||||
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; }
|
||||
const QString publicKey = QStringLiteral(":/simcae/update-client/manifest-public-key.pem");
|
||||
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package RSA signature is invalid."); return false; }
|
||||
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
|
||||
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; }
|
||||
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; }
|
||||
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The signed offline package metadata is invalid."); return false; }
|
||||
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The manifest digest does not match the package signature."); return false; }
|
||||
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
|
||||
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; }
|
||||
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid."); return false; }
|
||||
manifest.insert("signature", wrapper.value("manifest_signature").toString());
|
||||
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
|
||||
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; }
|
||||
if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; }
|
||||
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The package metadata and manifest identity do not match."); return false; }
|
||||
if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid."); return false; }
|
||||
const qint64 payloadStart = 16 + qint64(headerSize);
|
||||
const QJsonArray entries = packageMeta.value("files").toArray();
|
||||
for (const QJsonValue& value : entries) {
|
||||
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
|
||||
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; }
|
||||
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-bounds data: %1").arg(path); return false; }
|
||||
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
|
||||
if (stagingDir.isEmpty()) continue;
|
||||
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; }
|
||||
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; }
|
||||
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare the offline file: %1").arg(path); return false; }
|
||||
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create the staged file: %1").arg(path); return false; }
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
|
||||
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); }
|
||||
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; }
|
||||
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Failed to read the offline file: %1").arg(path); return false; } hash.addData(block); remaining -= block.size(); }
|
||||
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Offline file hash validation or writing failed: %1").arg(path); return false; }
|
||||
}
|
||||
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; }
|
||||
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The number of files in the offline package does not match the manifest."); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
QList<FileDownloadItem> signedFiles;
|
||||
m_fileItems.clear();
|
||||
if (!m_manifestSignatureVerified
|
||||
|| !validateOnlineManifest(m_manifest, appId, channel, targetVer, versionId, &signedFiles))
|
||||
{
|
||||
qDebug() << "Download URL request rejected because the signed manifest is not valid for the request";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
if (signedFiles.isEmpty())
|
||||
{
|
||||
qDebug() << "Download URL request rejected because the signed manifest has no files";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
@@ -395,25 +576,94 @@ void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel,
|
||||
QJsonArray emptyFiles;
|
||||
body["files"] = emptyFiles;
|
||||
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
m_http.postRequest(url, body, [this, signedFiles](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)
|
||||
const QJsonValue filesValue = resp.value("files");
|
||||
if (!filesValue.isArray())
|
||||
{
|
||||
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;
|
||||
qDebug() << "Download URL response files field is invalid";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray responseFiles = filesValue.toArray();
|
||||
if (responseFiles.size() != signedFiles.size())
|
||||
{
|
||||
qDebug() << "Download URL response file count does not match the signed manifest";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
QMap<QString, int> signedIndexes;
|
||||
for (int index = 0; index < signedFiles.size(); ++index)
|
||||
signedIndexes.insert(signedFiles.at(index).path, index);
|
||||
|
||||
QList<FileDownloadItem> boundFiles = signedFiles;
|
||||
QSet<QString> responsePaths;
|
||||
for (const QJsonValue& value : responseFiles)
|
||||
{
|
||||
if (!value.isObject())
|
||||
{
|
||||
qDebug() << "Download URL response contains a non-object file item";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject responseFile = value.toObject();
|
||||
const QJsonValue pathValue = responseFile.value("path");
|
||||
const QJsonValue urlValue = responseFile.value("url");
|
||||
const QJsonValue shaValue = responseFile.value("sha256");
|
||||
const QUrl downloadUrl(urlValue.toString());
|
||||
qint64 size = -1;
|
||||
if (!pathValue.isString() || pathValue.toString().isEmpty()
|
||||
|| !urlValue.isString() || urlValue.toString().trimmed().isEmpty()
|
||||
|| !downloadUrl.isValid() || downloadUrl.host().isEmpty()
|
||||
|| (downloadUrl.scheme().compare("http", Qt::CaseInsensitive) != 0
|
||||
&& downloadUrl.scheme().compare("https", Qt::CaseInsensitive) != 0)
|
||||
|| !shaValue.isString() || !isSha256(shaValue.toString())
|
||||
|| !jsonNonNegativeInteger(responseFile.value("size"), &size))
|
||||
{
|
||||
qDebug() << "Download URL response contains invalid file metadata";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QString path = pathValue.toString();
|
||||
if (responsePaths.contains(path) || !signedIndexes.contains(path))
|
||||
{
|
||||
qDebug() << "Download URL response contains a duplicate or extra file:" << path;
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
responsePaths.insert(path);
|
||||
|
||||
const int index = signedIndexes.value(path);
|
||||
const FileDownloadItem& signedFile = signedFiles.at(index);
|
||||
if (shaValue.toString().compare(signedFile.sha256, Qt::CaseInsensitive) != 0
|
||||
|| size != signedFile.size)
|
||||
{
|
||||
qDebug() << "Download URL response metadata differs from the signed manifest:" << path;
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
boundFiles[index].url = urlValue.toString();
|
||||
}
|
||||
|
||||
if (responsePaths.size() != signedFiles.size())
|
||||
{
|
||||
qDebug() << "Download URL response is missing signed manifest files";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
m_fileItems = boundFiles;
|
||||
qDebug() << "Bound download URLs to" << m_fileItems.size()
|
||||
<< "signed manifest files";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -514,7 +764,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
||||
|
||||
if (existingSize > 0 && httpStatus == 200)
|
||||
{
|
||||
// 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。
|
||||
// The server ignored Range, so restart instead of appending a full response to the partial file.
|
||||
qDebug() << "Server ignored Range; restart full download:" << savePath;
|
||||
QFile::remove(partPath);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ 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 verifyManifestSignature(const QString& publicKeyPath = ":/simcae/update-client/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);
|
||||
@@ -53,6 +53,9 @@ signals:
|
||||
private:
|
||||
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
|
||||
qint64 expectedSize, const QString& resumePartPath);
|
||||
bool validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
|
||||
const QString& channel, const QString& targetVer,
|
||||
int versionId, QList<FileDownloadItem>* fileItems) const;
|
||||
bool isSafeRelativePath(const QString& path) const;
|
||||
bool isRuntimeProtectedPath(const QString& path) const;
|
||||
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
|
||||
@@ -63,6 +66,7 @@ private:
|
||||
QJsonObject m_manifest;
|
||||
QString m_manifestText;
|
||||
QList<FileDownloadItem> m_fileItems;
|
||||
mutable bool m_manifestSignatureVerified = false;
|
||||
bool m_downloadAllOk = false;
|
||||
qint64 m_downloadTotalBytes = 0;
|
||||
qint64 m_downloadCompletedBytes = 0;
|
||||
@@ -70,4 +74,4 @@ private:
|
||||
QString m_currentDownloadPath;
|
||||
QString m_offlineError;
|
||||
QElapsedTimer m_downloadTimer;
|
||||
};
|
||||
};
|
||||
|
||||
+193
-78
@@ -1,4 +1,3 @@
|
||||
#include <windows.h>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
@@ -11,7 +10,6 @@
|
||||
#include <QProgressDialog>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QTextCodec>
|
||||
#include <QThread>
|
||||
#include "UpdaterLogic.h"
|
||||
#include "UpdateTransaction.h"
|
||||
@@ -19,15 +17,17 @@
|
||||
#include "ConfigHelper.h"
|
||||
#include "TicketHelper.h"
|
||||
#include "PolicyHelper.h"
|
||||
#include "TranslationHelper.h"
|
||||
#include <QVersionNumber>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
SetConsoleOutputCP(65001);
|
||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("Marsco Updater");
|
||||
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
UpdaterLogic logic;
|
||||
QString offlinePackagePath;
|
||||
QString appId;
|
||||
@@ -37,7 +37,7 @@ int main(int argc, char* argv[])
|
||||
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
|
||||
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath)) {
|
||||
QMessageBox::critical(nullptr, "离线包无效", logic.offlineError());
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Updater", "Invalid Offline Package"), logic.offlineError());
|
||||
return -1;
|
||||
}
|
||||
const QJsonObject packageManifest = logic.getManifest();
|
||||
@@ -47,7 +47,10 @@ int main(int argc, char* argv[])
|
||||
targetVersionId = packageManifest.value("manifest_seq").toInt();
|
||||
} else {
|
||||
if (argc < 5) {
|
||||
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Invalid Updater Arguments"),
|
||||
QCoreApplication::translate("Updater", "The updater requires online update arguments or an offline update package."));
|
||||
return -1;
|
||||
}
|
||||
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
|
||||
@@ -66,23 +69,39 @@ int main(int argc, char* argv[])
|
||||
const QString updateDir = config.updateRoot();
|
||||
QDir().mkpath(updateDir);
|
||||
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
||||
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
|
||||
QCoreApplication::translate("Updater", "The update package application or channel does not match this installation."));
|
||||
return -1;
|
||||
}
|
||||
QString fromVersion = config.getValue("App", "current_version");
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
PolicyHelper offlinePolicy(runtimeDir);
|
||||
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
||||
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
||||
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
|
||||
return -1;
|
||||
}
|
||||
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
||||
QVersionNumber::fromString(fromVersion)) < 0
|
||||
&& !offlinePolicy.allowRollback()) {
|
||||
QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。");
|
||||
return -1;
|
||||
}
|
||||
PolicyHelper updatePolicy(runtimeDir);
|
||||
const bool policyApplicable =
|
||||
updatePolicy.loadPolicy() && updatePolicy.isValid()
|
||||
&& updatePolicy.isApplicable(appId, channel, fromVersion)
|
||||
&& !updatePolicy.isExpired()
|
||||
&& updatePolicy.isVersionAllowed(targetVersion);
|
||||
if (!policyApplicable
|
||||
|| (!offlinePackagePath.isEmpty() && !updatePolicy.isOfflineAllowed()))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Denied"),
|
||||
QCoreApplication::translate(
|
||||
"Updater",
|
||||
"The local signed policy is invalid, expired, not applicable to this installation, or does not allow the requested update."));
|
||||
return -1;
|
||||
}
|
||||
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
||||
QVersionNumber::fromString(fromVersion)) < 0
|
||||
&& !updatePolicy.allowRollback())
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Downgrade Prohibited"),
|
||||
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an older version."));
|
||||
return -1;
|
||||
}
|
||||
QString deviceId = config.getValue("Update", "device_id");
|
||||
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
||||
@@ -91,21 +110,27 @@ int main(int argc, char* argv[])
|
||||
QString restoredVersion;
|
||||
QString recoveryError;
|
||||
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败",
|
||||
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Recovery Failed"),
|
||||
QCoreApplication::translate("Updater", "The previous update did not finish, and the old version could not be restored: %1\nDo not continue running the software. Contact an administrator.")
|
||||
.arg(recoveryError));
|
||||
return -1;
|
||||
}
|
||||
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
|
||||
if (!config.setValue("App", "current_version", restoredVersion)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Recovery Failed"),
|
||||
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Check access to the system state store."));
|
||||
return -1;
|
||||
}
|
||||
fromVersion = restoredVersion;
|
||||
}
|
||||
}
|
||||
|
||||
QProgressDialog progress("正在准备更新...", QString(), 0, 100);
|
||||
progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion));
|
||||
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing the update..."), QString(), 0, 100);
|
||||
progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
|
||||
progress.setCancelButton(nullptr);
|
||||
progress.setWindowModality(Qt::ApplicationModal);
|
||||
progress.setMinimumDuration(0);
|
||||
@@ -133,10 +158,11 @@ int main(int argc, char* argv[])
|
||||
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);
|
||||
const QString fileText = path.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Preparing the download...")
|
||||
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
|
||||
progress.setValue(value);
|
||||
progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
|
||||
progress.setLabelText(QString("%1\n%2 / %3 | %4/s")
|
||||
.arg(fileText, formatBytes(received), formatBytes(total),
|
||||
formatBytes(static_cast<qint64>(bytesPerSecond))));
|
||||
QApplication::processEvents();
|
||||
@@ -196,7 +222,7 @@ int main(int argc, char* argv[])
|
||||
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
||||
FileHelper::killProcess(mainExecutable);
|
||||
transaction.markRollbackRequired(reason);
|
||||
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
||||
progress.setLabelText(QCoreApplication::translate("Updater", "Handing rollback over to Bootstrap..."));
|
||||
QApplication::processEvents();
|
||||
if (launchBootstrap("rollback")) {
|
||||
progress.close();
|
||||
@@ -205,7 +231,7 @@ int main(int argc, char* argv[])
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, title,
|
||||
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
||||
reason + QCoreApplication::translate("Updater", "\n\nBootstrap could not be started to perform the rollback. Do not continue running the software. Contact an administrator."));
|
||||
return -1;
|
||||
};
|
||||
|
||||
@@ -214,8 +240,10 @@ int main(int argc, char* argv[])
|
||||
if (!transaction.resumeExisting(&resumeError)) {
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "事务续办失败",
|
||||
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Transaction Resume Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
|
||||
return -1;
|
||||
}
|
||||
fromVersion = transaction.fromVersion();
|
||||
@@ -227,38 +255,60 @@ int main(int argc, char* argv[])
|
||||
reportUpdateResult(false);
|
||||
if (stateOk) launchMainApp();
|
||||
progress.close();
|
||||
QMessageBox::warning(nullptr, "更新已回滚",
|
||||
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。"
|
||||
: "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。");
|
||||
QMessageBox::warning(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Rolled Back"),
|
||||
stateOk
|
||||
? QCoreApplication::translate("Updater", "The new version could not be installed or started. The old version was restored and started automatically.")
|
||||
: QCoreApplication::translate("Updater", "The old files were restored, but the old version number could not be written back. Check permissions on the system state store."));
|
||||
return stateOk ? 0 : -1;
|
||||
}
|
||||
if (bootstrapResult != "success") {
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "自动回滚失败",
|
||||
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Automatic Rollback Failed"),
|
||||
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator."));
|
||||
return -1;
|
||||
}
|
||||
} else if (!transaction.initialize()) {
|
||||
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save its state."),
|
||||
"transaction_init_failed");
|
||||
} else if (!offlinePackagePath.isEmpty()) {
|
||||
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
|
||||
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
|
||||
return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
|
||||
"offline_marker_failed");
|
||||
}
|
||||
|
||||
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
|
||||
setProgress(
|
||||
resumingFromBootstrap ? 72 : 10,
|
||||
offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Fetching and validating the version manifest...")
|
||||
: QCoreApplication::translate("Updater", "Validating the offline update package..."));
|
||||
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
|
||||
if (resumingFromBootstrap) {
|
||||
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
||||
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "The signed manifest cache could not be read after Bootstrap installation."));
|
||||
} else if (offlinePackagePath.isEmpty()) {
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
||||
}
|
||||
if (!logic.verifyManifestSignature()) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
||||
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Security Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The version manifest signature could not be revalidated after Bootstrap installation."));
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Security Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Contact an administrator."),
|
||||
"manifest_signature_invalid");
|
||||
}
|
||||
QStringList obsoletePaths;
|
||||
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
||||
@@ -274,45 +324,68 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
if (!logic.saveManifestCache(manifestCacheDir)) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。");
|
||||
return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
|
||||
"manifest_cache_failed");
|
||||
}
|
||||
|
||||
if (!resumingFromBootstrap) {
|
||||
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件...");
|
||||
setProgress(
|
||||
25,
|
||||
offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
|
||||
: QCoreApplication::translate("Updater", "Preparing offline package files..."));
|
||||
if (offlinePackagePath.isEmpty())
|
||||
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||
if (logic.getFileList().isEmpty())
|
||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "No Update Files"),
|
||||
QCoreApplication::translate("Updater", "The server returned no version files. The update has stopped."),
|
||||
"empty_file_list");
|
||||
|
||||
QStorageInfo storage(updateDir);
|
||||
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"
|
||||
"所需空间已包含下载文件、旧版本备份和安全余量。")
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Insufficient Disk Space"),
|
||||
QCoreApplication::translate("Updater", "The update requires at least %1 of free space, but the installation drive has only %2 available.\nThe required space includes downloaded files, the old-version backup, and a safety margin.")
|
||||
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
|
||||
"disk_space_insufficient");
|
||||
}
|
||||
|
||||
const QString stagingDir = transaction.stagingDir();
|
||||
setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size()));
|
||||
setProgress(
|
||||
30,
|
||||
(offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Downloading and validating %1 version files...")
|
||||
: QCoreApplication::translate("Updater", "Extracting and validating %1 offline files..."))
|
||||
.arg(logic.getFileList().size()));
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
|
||||
return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid");
|
||||
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"), logic.offlineError(), "offline_package_invalid");
|
||||
} else {
|
||||
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
|
||||
logic.reportDownloadResult(appId, channel, targetVersion, false);
|
||||
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Download Failed"),
|
||||
QCoreApplication::translate("Updater", "Some files could not be downloaded or failed SHA-256 validation. Check the network and try again."),
|
||||
"download_failed");
|
||||
}
|
||||
logic.reportDownloadResult(appId, channel, targetVersion, true);
|
||||
}
|
||||
|
||||
setProgress(58, "正在校验完整版本文件...");
|
||||
setProgress(58, QCoreApplication::translate("Updater", "Validating the complete version files..."));
|
||||
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
||||
return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "File Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
|
||||
"staging_verify_failed");
|
||||
|
||||
QStringList changedPaths;
|
||||
QDir stagingRoot(stagingDir);
|
||||
@@ -324,24 +397,39 @@ int main(int argc, char* argv[])
|
||||
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
|
||||
for (const QString& path : changedPaths) {
|
||||
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
||||
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Bootstrap Cannot Update Itself"),
|
||||
QCoreApplication::translate("Updater", "This version contains a new %1. Upgrade this component with the installer, then republish the application version.").arg(bootstrapManifestPath),
|
||||
"bootstrap_self_update_blocked");
|
||||
}
|
||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Transaction Recording Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the validated-file or pending-deletion lists. The update has stopped."),
|
||||
"transaction_record_failed");
|
||||
|
||||
setProgress(66, "正在关闭主程序...");
|
||||
setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
|
||||
if (!FileHelper::killProcess(mainExecutable))
|
||||
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Cannot Close Main Application"),
|
||||
QCoreApplication::translate("Updater", "%1 is still running. Close it manually and try again.").arg(mainExecutable),
|
||||
"mainapp_close_failed");
|
||||
|
||||
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
|
||||
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 deletions...")
|
||||
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
||||
if (!transaction.backupCurrentFiles())
|
||||
return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "backup_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Backup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot back up the current version files. The new version has not been installed. Check disk space and directory permissions."),
|
||||
"backup_failed");
|
||||
|
||||
const QString planFile = bootstrapPlanFile();
|
||||
QSaveFile plan(planFile);
|
||||
if (!plan.open(QIODevice::WriteOnly))
|
||||
return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
|
||||
"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();
|
||||
@@ -349,43 +437,63 @@ int main(int argc, char* argv[])
|
||||
for (const QString& path : changedPaths) {
|
||||
if (!writePlanItem('C', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
|
||||
"bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
for (const QString& path : obsoletePaths) {
|
||||
if (!writePlanItem('D', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
|
||||
"bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
if (!plan.commit() || !transaction.markAwaitingBootstrap())
|
||||
return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
|
||||
"bootstrap_plan_failed");
|
||||
|
||||
setProgress(78, "正在将安装工作移交给 Bootstrap...");
|
||||
setProgress(78, QCoreApplication::translate("Updater", "Handing installation over to Bootstrap..."));
|
||||
if (!launchBootstrap("install"))
|
||||
return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot start the independent update handoff program: %1").arg(bootstrapPath),
|
||||
"bootstrap_start_failed");
|
||||
progress.close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
setProgress(82, "正在校验 Bootstrap 安装结果...");
|
||||
setProgress(82, QCoreApplication::translate("Updater", "Validating the Bootstrap installation result..."));
|
||||
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
|
||||
return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Installation Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version files failed validation after installation."));
|
||||
for (const QString& path : transaction.obsoletePaths()) {
|
||||
if (QFile::exists(QDir(targetDir).filePath(path)))
|
||||
return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
|
||||
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
|
||||
}
|
||||
|
||||
setProgress(89, "正在保存新版本状态...");
|
||||
setProgress(89, QCoreApplication::translate("Updater", "Saving the new version state..."));
|
||||
if (!config.setValue("App", "current_version", targetVersion)
|
||||
|| config.getValue("App", "current_version") != targetVersion)
|
||||
return delegateRollback("状态保存失败", "无法保存当前版本号。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "State Save Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the current version number."));
|
||||
|
||||
const QString healthFile = transaction.healthFile();
|
||||
QFile::remove(healthFile);
|
||||
setProgress(94, "正在启动新版本并等待健康确认...");
|
||||
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for a health confirmation..."));
|
||||
if (!launchMainApp(healthFile))
|
||||
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "%1 could not be started.").arg(mainExecutable));
|
||||
|
||||
QElapsedTimer healthTimer;
|
||||
healthTimer.start();
|
||||
@@ -394,17 +502,24 @@ int main(int argc, char* argv[])
|
||||
QThread::msleep(100);
|
||||
}
|
||||
if (!QFile::exists(healthFile))
|
||||
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version did not complete its startup health confirmation within %1 milliseconds.").arg(healthCheckTimeoutMs));
|
||||
|
||||
setProgress(99, "正在提交更新事务...");
|
||||
setProgress(99, QCoreApplication::translate("Updater", "Committing the update transaction..."));
|
||||
if (!transaction.commit())
|
||||
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Transaction Commit Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version started, but the update transaction could not be committed."));
|
||||
|
||||
QFile::remove(healthFile);
|
||||
QFile::remove(bootstrapPlanFile());
|
||||
reportUpdateResult(true);
|
||||
progress.setValue(100);
|
||||
progress.close();
|
||||
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Complete"),
|
||||
QCoreApplication::translate("Updater", "The software was updated successfully to %1 and passed the startup health check.").arg(targetVersion));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user