feat(client): 迁移Hub更新客户端实现
This commit is contained in:
+277
-218
@@ -13,10 +13,13 @@
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QApplication>
|
||||
#include <algorithm>
|
||||
#include "ConfigHelper.h"
|
||||
#include <QSaveFile>
|
||||
#include <QApplication>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
#include <algorithm>
|
||||
#include "ConfigHelper.h"
|
||||
#include "UpdatePathPolicy.h"
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/pem.h>
|
||||
@@ -25,15 +28,44 @@
|
||||
#include <openssl/err.h>
|
||||
#endif
|
||||
|
||||
UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
namespace {
|
||||
|
||||
QString trimBaseUrl(QString value)
|
||||
{
|
||||
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
|
||||
value = value.trimmed();
|
||||
while (value.endsWith(QLatin1Char('/')))
|
||||
value.chop(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
bool configFlag(const QString& key)
|
||||
{
|
||||
const QString value = configValue(key).toLower();
|
||||
return value == QStringLiteral("true")
|
||||
|| value == QStringLiteral("1")
|
||||
|| value == QStringLiteral("yes")
|
||||
|| value == QStringLiteral("on");
|
||||
}
|
||||
|
||||
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
|
||||
{
|
||||
const QString trimmed = value.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
query.addQueryItem(key, trimmed);
|
||||
}
|
||||
|
||||
namespace {
|
||||
QString serverDetailMessage(const QJsonObject& response)
|
||||
{
|
||||
const QString msg = response.value(QStringLiteral("msg")).toString();
|
||||
if (!msg.isEmpty())
|
||||
return msg;
|
||||
|
||||
const QJsonValue detail = response.value(QStringLiteral("detail"));
|
||||
if (detail.isObject()) {
|
||||
const QJsonObject obj = detail.toObject();
|
||||
@@ -44,52 +76,115 @@ QString serverDetailMessage(const QJsonObject& response)
|
||||
}
|
||||
return detail.toString();
|
||||
}
|
||||
|
||||
qint64 manifestFileSize(const QJsonObject& file)
|
||||
{
|
||||
if (file.contains(QStringLiteral("sizeBytes")))
|
||||
return file.value(QStringLiteral("sizeBytes")).toVariant().toLongLong();
|
||||
if (file.contains(QStringLiteral("size")))
|
||||
return file.value(QStringLiteral("size")).toVariant().toLongLong();
|
||||
return -1;
|
||||
}
|
||||
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
|
||||
QString absoluteDownloadUrl(const QString& baseUrl, const QString& downloadUrl)
|
||||
{
|
||||
const QString trimmed = downloadUrl.trimmed();
|
||||
if (trimmed.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)
|
||||
|| trimmed.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive))
|
||||
return trimmed;
|
||||
if (trimmed.startsWith(QLatin1Char('/')))
|
||||
return trimBaseUrl(baseUrl) + trimmed;
|
||||
return trimBaseUrl(baseUrl) + QLatin1Char('/') + trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_serverAddr = trimBaseUrl(ConfigHelper::instance().getValue("Server", "api_base_url"));
|
||||
}
|
||||
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer,
|
||||
int versionId, const QString& releaseId)
|
||||
{
|
||||
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
|
||||
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
|
||||
m_error.clear();
|
||||
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, appId, channel, targetVer, versionId](int code, const QJsonObject& resp)
|
||||
Q_UNUSED(versionId);
|
||||
m_manifestSha256.clear();
|
||||
m_manifestSignature.clear();
|
||||
m_manifestSignatureAlg.clear();
|
||||
m_manifestKeyId.clear();
|
||||
m_manifestSigned = false;
|
||||
m_fileItems.clear();
|
||||
|
||||
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
|
||||
QUrlQuery query;
|
||||
addQueryValue(query, QStringLiteral("releaseId"), releaseId);
|
||||
addQueryValue(query, QStringLiteral("productCode"), appId);
|
||||
addQueryValue(query, QStringLiteral("version"), targetVer);
|
||||
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
|
||||
addQueryValue(query, QStringLiteral("channel"), channel);
|
||||
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
|
||||
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
|
||||
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
|
||||
url.setQuery(query);
|
||||
|
||||
m_http.getRequest(url.toString(QUrl::FullyEncoded),
|
||||
[this, appId, channel, targetVer, releaseId](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();
|
||||
qDebug() << "Manifest API returned code:" << code;
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_manifestSha256.clear();
|
||||
m_manifestSignature.clear();
|
||||
m_manifestSignatureAlg.clear();
|
||||
m_manifestKeyId.clear();
|
||||
m_manifestSigned = false;
|
||||
|
||||
if (code == 200)
|
||||
{
|
||||
const QJsonObject envelope = resp.value(QStringLiteral("data")).isObject()
|
||||
? resp.value(QStringLiteral("data")).toObject()
|
||||
: resp;
|
||||
QString manifestText = envelope.value(QStringLiteral("manifestText")).toString();
|
||||
if (manifestText.isEmpty())
|
||||
manifestText = envelope.value(QStringLiteral("manifest_text")).toString();
|
||||
const QJsonObject manifest = envelope.value(QStringLiteral("manifest")).toObject();
|
||||
if (!manifestText.isEmpty() && !manifest.isEmpty())
|
||||
{
|
||||
m_manifestText = manifestText;
|
||||
m_manifest = manifest;
|
||||
m_manifestSha256 = envelope.value(QStringLiteral("manifestSha256")).toString(
|
||||
envelope.value(QStringLiteral("manifest_sha256")).toString());
|
||||
m_manifestSignature = envelope.value(QStringLiteral("signature")).toString(
|
||||
m_manifest.value(QStringLiteral("signature")).toString());
|
||||
m_manifestSignatureAlg = envelope.value(QStringLiteral("signatureAlg")).toString(
|
||||
envelope.value(QStringLiteral("signature_alg")).toString());
|
||||
m_manifestKeyId = envelope.value(QStringLiteral("keyId")).toString(
|
||||
envelope.value(QStringLiteral("key_id")).toString());
|
||||
m_manifestSigned = envelope.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
|
||||
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);
|
||||
}
|
||||
}
|
||||
FileDownloadItem fi;
|
||||
fi.path = fileObj.value(QStringLiteral("path")).toString();
|
||||
fi.sha256 = fileObj.value(QStringLiteral("sha256")).toString();
|
||||
fi.size = manifestFileSize(fileObj);
|
||||
fi.url = absoluteDownloadUrl(m_serverAddr,
|
||||
fileObj.value(QStringLiteral("downloadUrl")).toString());
|
||||
m_fileItems.append(fi);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Manifest response missing fields";
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"Target version manifest response is incomplete. Stage: download target manifest. App: %1, channel: %2, version: %3, version id: %4.")
|
||||
.arg(appId, channel, targetVer, QString::number(versionId));
|
||||
"Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4.")
|
||||
.arg(appId, channel, targetVer, releaseId);
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -97,8 +192,8 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
|
||||
qDebug() << "Failed to get manifest";
|
||||
const QString detail = serverDetailMessage(resp);
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
|
||||
.arg(QString::number(code), appId, channel, targetVer, QString::number(versionId),
|
||||
"Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. Product: %2, channel: %3, version: %4, release id: %5.%6")
|
||||
.arg(QString::number(code), appId, channel, targetVer, releaseId,
|
||||
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
|
||||
}
|
||||
emit fetchUrlFinished();
|
||||
@@ -197,13 +292,30 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
"No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
|
||||
return false;
|
||||
}
|
||||
QString signature = m_manifest.value("signature").toString();
|
||||
if (signature.isEmpty())
|
||||
if (!m_manifestSha256.isEmpty()) {
|
||||
const QString actualSha = QString::fromLatin1(
|
||||
QCryptographicHash::hash(m_manifestText.toUtf8(), QCryptographicHash::Sha256).toHex());
|
||||
if (actualSha.compare(m_manifestSha256, Qt::CaseInsensitive) != 0) {
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"Manifest SHA-256 does not match the server envelope. Stage: manifest digest verification.\nExpected SHA-256: %1\nActual SHA-256: %2")
|
||||
.arg(m_manifestSha256, actualSha);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const bool requireSignature = configFlag(QStringLiteral("require_manifest_signature"));
|
||||
const QString signature = m_manifestSignature.trimmed();
|
||||
if (signature.isEmpty() || !m_manifestSigned)
|
||||
{
|
||||
qDebug() << "Manifest signature empty";
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"The manifest does not contain a signature. Stage: manifest signature verification.");
|
||||
return false;
|
||||
if (requireSignature) {
|
||||
qDebug() << "Manifest signature empty";
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification.");
|
||||
return false;
|
||||
}
|
||||
qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled.";
|
||||
m_error.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
QString path = publicKeyPath;
|
||||
@@ -242,9 +354,15 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject wrapper;
|
||||
wrapper["manifest"] = m_manifest;
|
||||
wrapper["manifest_text"] = m_manifestText;
|
||||
QJsonObject wrapper;
|
||||
wrapper["manifest"] = m_manifest;
|
||||
wrapper["manifestText"] = m_manifestText;
|
||||
wrapper["manifest_text"] = m_manifestText;
|
||||
wrapper["manifestSha256"] = m_manifestSha256;
|
||||
wrapper["signature"] = m_manifestSignature;
|
||||
wrapper["signatureAlg"] = m_manifestSignatureAlg;
|
||||
wrapper["keyId"] = m_manifestKeyId;
|
||||
wrapper["signed"] = m_manifestSigned;
|
||||
|
||||
QJsonDocument doc(wrapper);
|
||||
file.write(doc.toJson(QJsonDocument::Indented));
|
||||
@@ -277,15 +395,27 @@ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& ver
|
||||
}
|
||||
|
||||
QJsonObject wrapper = doc.object();
|
||||
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) {
|
||||
if (!wrapper.contains("manifest")
|
||||
|| (!wrapper.contains("manifestText") && !wrapper.contains("manifest_text"))) {
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.")
|
||||
.arg(version, filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
m_manifest = wrapper["manifest"].toObject();
|
||||
m_manifestText = wrapper["manifest_text"].toString();
|
||||
m_manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
|
||||
if (m_manifestText.isEmpty())
|
||||
m_manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
|
||||
m_manifestSha256 = wrapper.value(QStringLiteral("manifestSha256")).toString(
|
||||
wrapper.value(QStringLiteral("manifest_sha256")).toString());
|
||||
m_manifestSignature = wrapper.value(QStringLiteral("signature")).toString(
|
||||
m_manifest.value(QStringLiteral("signature")).toString());
|
||||
m_manifestSignatureAlg = wrapper.value(QStringLiteral("signatureAlg")).toString(
|
||||
wrapper.value(QStringLiteral("signature_alg")).toString());
|
||||
m_manifestKeyId = wrapper.value(QStringLiteral("keyId")).toString(
|
||||
wrapper.value(QStringLiteral("key_id")).toString());
|
||||
m_manifestSigned = wrapper.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
|
||||
qDebug() << "Loaded cached manifest" << version;
|
||||
m_error.clear();
|
||||
return true;
|
||||
@@ -339,40 +469,19 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
||||
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
||||
}
|
||||
|
||||
QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap"),
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("launcher"),
|
||||
QStringLiteral("launcher.exe"),
|
||||
QStringLiteral("updater"),
|
||||
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")
|
||||
};
|
||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||
if (!runtimePrefix.isEmpty()) {
|
||||
const QStringList runtimeProtected{
|
||||
QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("launcher"), QStringLiteral("launcher.exe"),
|
||||
QStringLiteral("updater"), 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")
|
||||
};
|
||||
for (const QString& path : runtimeProtected)
|
||||
protectedPaths.insert(runtimePrefix + "/" + path);
|
||||
}
|
||||
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;
|
||||
QStringList obsolete;
|
||||
QSet<QString> seen;
|
||||
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
|
||||
const QJsonObject item = value.toObject();
|
||||
if (item.contains(QStringLiteral("required"))
|
||||
&& !item.value(QStringLiteral("required")).toBool(true)) {
|
||||
continue;
|
||||
}
|
||||
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||
const QString folded = path.toCaseFolded();
|
||||
if (!isSafeRelativePath(path) || isRuntimeProtectedPath(path)
|
||||
|| newPaths.contains(folded) || seen.contains(folded))
|
||||
continue;
|
||||
seen.insert(folded);
|
||||
obsolete.append(path);
|
||||
}
|
||||
@@ -423,6 +532,15 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
|
||||
.arg(stage, version, path, fullPath);
|
||||
return false;
|
||||
}
|
||||
const qint64 expectedSize = manifestFileSize(fileObject);
|
||||
if (expectedSize >= 0 && QFileInfo(fullPath).size() != expectedSize)
|
||||
{
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"File size does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected size: %5 bytes\nActual size: %6 bytes")
|
||||
.arg(stage, version, path, fullPath,
|
||||
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
|
||||
return false;
|
||||
}
|
||||
const QString actualSha = calcLocalFileSha256(fullPath);
|
||||
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
|
||||
{
|
||||
@@ -462,17 +580,25 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
|
||||
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 = 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 = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; }
|
||||
manifest.insert("signature", wrapper.value("manifest_signature").toString());
|
||||
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
|
||||
m_manifestSha256 = packageMeta.value("manifest_sha256").toString();
|
||||
m_manifestSignature = wrapper.value("manifest_signature").toString();
|
||||
m_manifestSignatureAlg = wrapper.value("signature_alg").toString("RSA-SHA256");
|
||||
m_manifestKeyId = wrapper.value("key_id").toString();
|
||||
m_manifestSigned = !m_manifestSignature.isEmpty();
|
||||
const QString manifestProduct = manifest.value("productCode").toString(manifest.value("app_id").toString());
|
||||
const QString packageProduct = packageMeta.value("productCode").toString(packageMeta.value("app_id").toString());
|
||||
if (manifestProduct != packageProduct || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); 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();
|
||||
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 = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-range data: %1").arg(path); return false; }
|
||||
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
|
||||
if (isRuntimeProtectedPath(path)) continue;
|
||||
if (stagingDir.isEmpty()) continue;
|
||||
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare offline file: %1").arg(path); return false; }
|
||||
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create staged file: %1").arg(path); return false; }
|
||||
@@ -486,48 +612,18 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
|
||||
|
||||
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
Q_UNUSED(appId);
|
||||
Q_UNUSED(channel);
|
||||
Q_UNUSED(targetVer);
|
||||
Q_UNUSED(versionId);
|
||||
m_error.clear();
|
||||
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, appId, channel, targetVer, versionId](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";
|
||||
const QString detail = serverDetailMessage(resp);
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"Cannot get secure download URLs. Stage: request download URLs. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
|
||||
.arg(QString::number(code), appId, channel, targetVer, QString::number(versionId),
|
||||
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
|
||||
}
|
||||
emit fetchUrlFinished();
|
||||
});
|
||||
if (m_fileItems.isEmpty()) {
|
||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||
"The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads.");
|
||||
} else {
|
||||
qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest.";
|
||||
}
|
||||
emit fetchUrlFinished();
|
||||
}
|
||||
|
||||
|
||||
@@ -607,12 +703,15 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
||||
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) + "-");
|
||||
QNetworkAccessManager manager;
|
||||
manager.setProxy(QNetworkProxy::NoProxy);
|
||||
QNetworkRequest request{QUrl(url)};
|
||||
request.setTransferTimeout(60000);
|
||||
const QString clientToken = configValue(QStringLiteral("client_token"));
|
||||
if (!clientToken.isEmpty())
|
||||
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||
if (existingSize > 0)
|
||||
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
|
||||
|
||||
QNetworkReply* reply = manager.get(request);
|
||||
QEventLoop loop;
|
||||
@@ -636,12 +735,12 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
||||
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)
|
||||
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)
|
||||
{
|
||||
// The server ignored Range; the current file is "old fragment + full response" and must be redownloaded safely.
|
||||
qDebug() << "Server ignored Range; restart full download:" << savePath;
|
||||
@@ -710,41 +809,16 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
||||
return false;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
||||
{
|
||||
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap"),
|
||||
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")
|
||||
};
|
||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||
if (!runtimePrefix.isEmpty()) {
|
||||
const QStringList runtimeProtected{
|
||||
QStringLiteral("bootstrap"), 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")
|
||||
};
|
||||
for (const QString& protectedPath : runtimeProtected)
|
||||
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
||||
}
|
||||
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(":");
|
||||
}
|
||||
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
||||
{
|
||||
return UpdatePathPolicy::isFullUpdateProtectedPath(
|
||||
path, ConfigHelper::instance().runtimeRelativePath());
|
||||
}
|
||||
|
||||
bool UpdaterLogic::isSafeRelativePath(const QString& path) const
|
||||
{
|
||||
return UpdatePathPolicy::isSafeRelativePath(path);
|
||||
}
|
||||
|
||||
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
||||
const QStringList& obsoletePaths) const
|
||||
@@ -879,42 +953,27 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
|
||||
const QString& version, bool success)
|
||||
{
|
||||
QJsonArray files;
|
||||
for (const FileDownloadItem& item : m_fileItems)
|
||||
files.append(QJsonObject{{"path", item.path}, {"size", item.size}});
|
||||
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version},
|
||||
{"result", success ? "success" : "fail"}, {"files", files}};
|
||||
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
|
||||
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
|
||||
}
|
||||
|
||||
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["app_id"] = ConfigHelper::instance().getValue("App", "app_id");
|
||||
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;
|
||||
});
|
||||
}
|
||||
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
|
||||
const QString& version, bool success)
|
||||
{
|
||||
Q_UNUSED(appId);
|
||||
Q_UNUSED(channel);
|
||||
Q_UNUSED(version);
|
||||
Q_UNUSED(success);
|
||||
qDebug() << "Download result report is not part of the current SimCAE Hub API; skipped.";
|
||||
}
|
||||
|
||||
void UpdaterLogic::reportResult(const QString& deviceId,
|
||||
const QString& fromVer,
|
||||
const QString& toVer,
|
||||
bool success)
|
||||
{
|
||||
Q_UNUSED(deviceId);
|
||||
Q_UNUSED(fromVer);
|
||||
Q_UNUSED(toVer);
|
||||
Q_UNUSED(success);
|
||||
qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
|
||||
}
|
||||
|
||||
QList<FileDownloadItem> UpdaterLogic::getFileList() const
|
||||
{
|
||||
|
||||
@@ -24,7 +24,8 @@ class UpdaterLogic : public QObject
|
||||
public:
|
||||
explicit UpdaterLogic(QObject* parent = nullptr);
|
||||
|
||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer,
|
||||
int versionId, const QString& releaseId = QString());
|
||||
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;
|
||||
@@ -61,8 +62,13 @@ private:
|
||||
|
||||
HttpHelper m_http;
|
||||
QString m_serverAddr;
|
||||
QJsonObject m_manifest;
|
||||
QString m_manifestText;
|
||||
QJsonObject m_manifest;
|
||||
QString m_manifestText;
|
||||
QString m_manifestSha256;
|
||||
QString m_manifestSignature;
|
||||
QString m_manifestSignatureAlg;
|
||||
QString m_manifestKeyId;
|
||||
bool m_manifestSigned = false;
|
||||
QList<FileDownloadItem> m_fileItems;
|
||||
bool m_downloadAllOk = false;
|
||||
qint64 m_downloadTotalBytes = 0;
|
||||
|
||||
+21
-12
@@ -24,7 +24,7 @@
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("Marsco Updater");
|
||||
QApplication::setApplicationName("SimCAE Updater");
|
||||
QTranslator translator;
|
||||
if (translator.load(":/i18n/update-client_zh_CN.qm"))
|
||||
app.installTranslator(&translator);
|
||||
@@ -35,10 +35,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
UpdaterLogic logic;
|
||||
QString offlinePackagePath;
|
||||
QString appId;
|
||||
QString channel;
|
||||
QString targetVersion;
|
||||
int targetVersionId = 0;
|
||||
QString appId;
|
||||
QString channel;
|
||||
QString targetVersion;
|
||||
QString releaseId;
|
||||
int targetVersionId = 0;
|
||||
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
|
||||
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath)) {
|
||||
@@ -62,11 +63,13 @@ int main(int argc, char* argv[])
|
||||
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; 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());
|
||||
}
|
||||
for (int i = 5; i < argc; ++i) {
|
||||
const QString arg = argv[i];
|
||||
if (arg.startsWith("--bootstrap-resume="))
|
||||
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
||||
else if (arg.startsWith("--release-id="))
|
||||
releaseId = arg.mid(QString("--release-id=").size());
|
||||
}
|
||||
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
||||
const QString runtimeDir = QApplication::applicationDirPath();
|
||||
|
||||
@@ -74,7 +77,13 @@ int main(int argc, char* argv[])
|
||||
const QString targetDir = config.installRoot();
|
||||
const QString updateDir = config.updateRoot();
|
||||
QDir().mkpath(updateDir);
|
||||
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
||||
QString configuredProductCode = config.getValue("App", "product_code").trimmed();
|
||||
if (configuredProductCode.isEmpty())
|
||||
configuredProductCode = config.getValue("App", "app_id").trimmed();
|
||||
QString configuredChannel = config.getValue("App", "channel").trimmed();
|
||||
if (configuredChannel.isEmpty())
|
||||
configuredChannel = QStringLiteral("stable");
|
||||
if (appId != configuredProductCode || channel != configuredChannel) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
|
||||
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
|
||||
@@ -310,7 +319,7 @@ int main(int argc, char* argv[])
|
||||
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
|
||||
logic.errorString()));
|
||||
} else if (offlinePackagePath.isEmpty()) {
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId);
|
||||
}
|
||||
if (!logic.verifyManifestSignature()) {
|
||||
if (resumingFromBootstrap)
|
||||
|
||||
Reference in New Issue
Block a user