765 lines
32 KiB
C++
765 lines
32 KiB
C++
#include "UpdaterLogic.h"
|
|
#include <QSet>
|
|
#include <QDebug>
|
|
#include <QFileInfo>
|
|
#include <QFile>
|
|
#include <QCoreApplication>
|
|
#include <QEventLoop>
|
|
#include <QElapsedTimer>
|
|
#include <QThread>
|
|
#include <QNetworkRequest>
|
|
#include <QNetworkProxy>
|
|
#include <QJsonArray>
|
|
#include <QCryptographicHash>
|
|
#include <QDir>
|
|
#include <QJsonDocument>
|
|
#include <QSaveFile>
|
|
#include <QApplication>
|
|
#include <algorithm>
|
|
#include "ConfigHelper.h"
|
|
|
|
#ifdef HAVE_OPENSSL
|
|
#include <openssl/pem.h>
|
|
#include <openssl/evp.h>
|
|
#include <openssl/bio.h>
|
|
#include <openssl/err.h>
|
|
#endif
|
|
|
|
UpdaterLogic::UpdaterLogic(QObject* parent)
|
|
: QObject(parent)
|
|
{
|
|
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
|
|
}
|
|
|
|
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
|
{
|
|
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
|
|
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
|
|
QString url = m_serverAddr + "/api/v1/update/manifest";
|
|
QJsonObject body;
|
|
body["app_id"] = appId;
|
|
body["channel"] = channel;
|
|
body["version"] = targetVer;
|
|
body["version_id"] = versionId;
|
|
|
|
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
|
{
|
|
qDebug() << "Manifest API returned code:" << code;
|
|
m_manifest = QJsonObject();
|
|
m_manifestText.clear();
|
|
|
|
if (code == 200)
|
|
{
|
|
if (resp.contains("manifest_text") && resp.contains("manifest"))
|
|
{
|
|
m_manifestText = resp["manifest_text"].toString();
|
|
m_manifest = resp["manifest"].toObject();
|
|
qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
|
|
m_fileItems.clear();
|
|
QJsonArray files = m_manifest.value("files").toArray();
|
|
for (const QJsonValue& fileItem : files)
|
|
{
|
|
QJsonObject fileObj = fileItem.toObject();
|
|
FileDownloadItem fi;
|
|
fi.path = fileObj.value("path").toString();
|
|
fi.sha256 = fileObj.value("sha256").toString();
|
|
fi.size = fileObj.value("size").toVariant().toLongLong();
|
|
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL
|
|
m_fileItems.append(fi);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
qDebug() << "Manifest response missing fields";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
qDebug() << "Failed to get manifest";
|
|
}
|
|
emit fetchUrlFinished();
|
|
});
|
|
}
|
|
|
|
bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
|
|
{
|
|
#ifndef HAVE_OPENSSL
|
|
Q_UNUSED(payload);
|
|
Q_UNUSED(signatureBase64);
|
|
Q_UNUSED(publicKeyPath);
|
|
qDebug() << "OpenSSL not available, cannot verify manifest signature";
|
|
return false;
|
|
#else
|
|
QFile keyFile(publicKeyPath);
|
|
if (!keyFile.open(QIODevice::ReadOnly))
|
|
{
|
|
qDebug() << "Cannot open public key file:" << publicKeyPath;
|
|
return false;
|
|
}
|
|
|
|
QByteArray keyData = keyFile.readAll();
|
|
keyFile.close();
|
|
|
|
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
|
if (!bio)
|
|
{
|
|
qDebug() << "BIO_new_mem_buf failed";
|
|
return false;
|
|
}
|
|
|
|
EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL);
|
|
BIO_free(bio);
|
|
if (!pkey)
|
|
{
|
|
qDebug() << "PEM_read_bio_PUBKEY failed";
|
|
return false;
|
|
}
|
|
|
|
QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
|
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
|
|
if (!mdctx)
|
|
{
|
|
EVP_PKEY_free(pkey);
|
|
qDebug() << "EVP_MD_CTX_new failed";
|
|
return false;
|
|
}
|
|
|
|
bool ok = false;
|
|
if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1)
|
|
{
|
|
if (EVP_DigestVerifyUpdate(mdctx, payload.constData(), payload.size()) == 1)
|
|
{
|
|
if (EVP_DigestVerifyFinal(mdctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1)
|
|
{
|
|
ok = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
EVP_MD_CTX_free(mdctx);
|
|
EVP_PKEY_free(pkey);
|
|
|
|
if (!ok)
|
|
{
|
|
qDebug() << "Manifest signature verification failed";
|
|
}
|
|
return ok;
|
|
#endif
|
|
}
|
|
|
|
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
|
{
|
|
// 验签用的是客户端随 SDK 分发的公钥。
|
|
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
|
|
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
|
{
|
|
qDebug() << "No manifest available to verify";
|
|
return false;
|
|
}
|
|
QString signature = m_manifest.value("signature").toString();
|
|
if (signature.isEmpty())
|
|
{
|
|
qDebug() << "Manifest signature empty";
|
|
return false;
|
|
}
|
|
|
|
QString path = publicKeyPath;
|
|
if (!QFile::exists(path))
|
|
{
|
|
path = QApplication::applicationDirPath() + "/" + publicKeyPath;
|
|
}
|
|
return verifySignature(m_manifestText.toUtf8(), signature, path);
|
|
}
|
|
|
|
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
|
{
|
|
// 启动后的完整性检查依赖本地 Manifest 缓存。
|
|
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
|
|
if (m_manifest.isEmpty())
|
|
return false;
|
|
|
|
QDir dir(cacheDir);
|
|
if (!dir.exists() && !dir.mkpath("."))
|
|
return false;
|
|
|
|
QString version = m_manifest.value("version").toString();
|
|
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
|
QFile file(filePath);
|
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
|
return false;
|
|
|
|
QJsonObject wrapper;
|
|
wrapper["manifest"] = m_manifest;
|
|
wrapper["manifest_text"] = m_manifestText;
|
|
|
|
QJsonDocument doc(wrapper);
|
|
file.write(doc.toJson(QJsonDocument::Indented));
|
|
file.close();
|
|
qDebug() << "Manifest cached to" << filePath;
|
|
return true;
|
|
}
|
|
|
|
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
|
|
{
|
|
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
|
QFile file(filePath);
|
|
if (!file.exists() || !file.open(QIODevice::ReadOnly))
|
|
return false;
|
|
|
|
QByteArray raw = file.readAll();
|
|
file.close();
|
|
QJsonDocument doc = QJsonDocument::fromJson(raw);
|
|
if (!doc.isObject())
|
|
return false;
|
|
|
|
QJsonObject wrapper = doc.object();
|
|
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text"))
|
|
return false;
|
|
|
|
m_manifest = wrapper["manifest"].toObject();
|
|
m_manifestText = wrapper["manifest_text"].toString();
|
|
qDebug() << "Loaded cached manifest" << version;
|
|
return true;
|
|
}
|
|
|
|
QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const
|
|
{
|
|
QJsonObject copy = manifest;
|
|
copy.remove("signature");
|
|
|
|
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
|
|
QStringList keys = obj.keys();
|
|
std::sort(keys.begin(), keys.end(), std::less<QString>());
|
|
QJsonObject sorted;
|
|
for (const auto& key : keys)
|
|
{
|
|
QJsonValue value = obj.value(key);
|
|
if (value.isObject())
|
|
sorted.insert(key, self(self, value.toObject()));
|
|
else if (value.isArray())
|
|
{
|
|
QJsonArray sortedArray;
|
|
for (const auto& element : value.toArray())
|
|
{
|
|
if (element.isObject())
|
|
sortedArray.append(self(self, element.toObject()));
|
|
else
|
|
sortedArray.append(element);
|
|
}
|
|
sorted.insert(key, sortedArray);
|
|
}
|
|
else
|
|
{
|
|
sorted.insert(key, value);
|
|
}
|
|
}
|
|
return sorted;
|
|
};
|
|
|
|
QJsonObject sorted = sortObject(sortObject, copy);
|
|
QJsonDocument doc(sorted);
|
|
return doc.toJson(QJsonDocument::Compact);
|
|
}
|
|
|
|
|
|
QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest) const
|
|
{
|
|
QSet<QString> newPaths;
|
|
for (const QJsonValue& value : m_manifest.value("files").toArray()) {
|
|
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
|
|
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
|
}
|
|
|
|
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;
|
|
seen.insert(folded);
|
|
obsolete.append(path);
|
|
}
|
|
obsolete.sort(Qt::CaseInsensitive);
|
|
return obsolete;
|
|
}
|
|
|
|
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
|
|
{
|
|
if (m_manifest.isEmpty())
|
|
return false;
|
|
|
|
const QJsonArray files = m_manifest.value("files").toArray();
|
|
for (const auto& item : files)
|
|
{
|
|
const QJsonObject fileObject = item.toObject();
|
|
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
|
|
const QString expectedSha = fileObject.value("sha256").toString();
|
|
if (!isSafeRelativePath(path))
|
|
return false;
|
|
if (isRuntimeProtectedPath(path)) {
|
|
qDebug() << "Runtime-protected manifest entry ignored:" << path;
|
|
continue;
|
|
}
|
|
|
|
QString fullPath = QDir(stagingDir).filePath(path);
|
|
if (!QFile::exists(fullPath) && !installedDir.isEmpty())
|
|
fullPath = QDir(installedDir).filePath(path);
|
|
|
|
if (!QFile::exists(fullPath))
|
|
{
|
|
qDebug() << "Manifest file missing from staging and installation:" << path;
|
|
return false;
|
|
}
|
|
const QString actualSha = calcLocalFileSha256(fullPath);
|
|
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
|
|
{
|
|
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
|
|
return false;
|
|
}
|
|
}
|
|
qDebug() << "Full manifest validation passed using staging plus installed files";
|
|
return true;
|
|
}
|
|
|
|
bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir)
|
|
{
|
|
m_offlineError.clear();
|
|
QFile package(packagePath);
|
|
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 = 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 = 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 = QCoreApplication::translate("UpdaterLogic", "The offline package header JSON is invalid"); 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 = 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 = QCoreApplication::translate("UpdaterLogic", "The offline package signature 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 = 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; }
|
|
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 = 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 (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; }
|
|
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 = QCoreApplication::translate("UpdaterLogic", "Failed to read 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 verification or write failed: %1").arg(path); return false; }
|
|
}
|
|
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package file count does not match the manifest"); return false; }
|
|
return true;
|
|
}
|
|
|
|
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
|
{
|
|
QString url = m_serverAddr + "/api/v1/update/download-url";
|
|
QJsonObject body;
|
|
body["app_id"] = appId;
|
|
body["channel"] = channel;
|
|
body["version"] = targetVer;
|
|
body["version_id"] = versionId;
|
|
|
|
QJsonArray emptyFiles;
|
|
body["files"] = emptyFiles;
|
|
|
|
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
|
{
|
|
qDebug() << "Download URL API returned code:" << code;
|
|
m_fileItems.clear();
|
|
|
|
if (code == 200)
|
|
{
|
|
QJsonArray fileArr = resp["files"].toArray();
|
|
for (auto item : fileArr)
|
|
{
|
|
QJsonObject obj = item.toObject();
|
|
FileDownloadItem fi;
|
|
fi.path = obj["path"].toString();
|
|
fi.url = obj["url"].toString();
|
|
fi.sha256 = obj["sha256"].toString();
|
|
fi.size = obj["size"].toVariant().toLongLong();
|
|
m_fileItems.append(fi);
|
|
qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
qDebug() << "Failed to get download URL";
|
|
}
|
|
emit fetchUrlFinished();
|
|
});
|
|
}
|
|
|
|
|
|
QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const
|
|
{
|
|
QFile f(filePath);
|
|
if (!f.open(QIODevice::ReadOnly))
|
|
return "";
|
|
QCryptographicHash hash(QCryptographicHash::Sha256);
|
|
while (!f.atEnd())
|
|
{
|
|
QByteArray block = f.read(4096);
|
|
hash.addData(block);
|
|
}
|
|
f.close();
|
|
return hash.result().toHex();
|
|
}
|
|
|
|
bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath,
|
|
const QString& expectSha256, qint64 expectedSize,
|
|
const QString& resumePartPath)
|
|
{
|
|
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
|
|
if (!QDir().mkpath(QFileInfo(partPath).path())) return false;
|
|
if (QFile::exists(savePath)
|
|
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
|
return true;
|
|
QFile::remove(savePath);
|
|
|
|
for (int attempt = 0; attempt < 4; ++attempt)
|
|
{
|
|
qint64 existingSize = QFileInfo(partPath).size();
|
|
if (expectedSize >= 0 && existingSize > expectedSize)
|
|
{
|
|
QFile::remove(partPath);
|
|
existingSize = 0;
|
|
}
|
|
if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0)
|
|
{
|
|
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
|
{
|
|
QFile::remove(savePath);
|
|
return QFile::rename(partPath, savePath);
|
|
}
|
|
QFile::remove(partPath);
|
|
existingSize = 0;
|
|
}
|
|
|
|
QFile partFile(partPath);
|
|
const QIODevice::OpenMode mode = QIODevice::WriteOnly
|
|
| (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate);
|
|
if (!partFile.open(mode))
|
|
{
|
|
qDebug() << "Cannot open partial download:" << partPath;
|
|
return false;
|
|
}
|
|
|
|
QNetworkAccessManager manager;
|
|
manager.setProxy(QNetworkProxy::NoProxy);
|
|
QNetworkRequest request(url);
|
|
request.setTransferTimeout(60000);
|
|
if (existingSize > 0)
|
|
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
|
|
|
|
QNetworkReply* reply = manager.get(request);
|
|
QEventLoop loop;
|
|
bool writeOk = true;
|
|
QObject::connect(reply, &QNetworkReply::readyRead, [&]() {
|
|
const QByteArray data = reply->readAll();
|
|
if (!data.isEmpty()) {
|
|
if (partFile.write(data) != data.size()) writeOk = false;
|
|
m_sessionDownloadedBytes += data.size();
|
|
const qint64 received = qMin(m_downloadTotalBytes,
|
|
m_downloadCompletedBytes + partFile.size());
|
|
const double speed = m_downloadTimer.elapsed() > 0
|
|
? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0;
|
|
emit downloadProgress(received, m_downloadTotalBytes, m_currentDownloadPath, speed);
|
|
}
|
|
});
|
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
|
loop.exec();
|
|
const QByteArray remaining = reply->readAll();
|
|
if (!remaining.isEmpty() && partFile.write(remaining) != remaining.size()) writeOk = false;
|
|
partFile.flush();
|
|
partFile.close();
|
|
|
|
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
const bool networkOk = reply->error() == QNetworkReply::NoError;
|
|
const QString networkError = reply->errorString();
|
|
reply->deleteLater();
|
|
|
|
if (existingSize > 0 && httpStatus == 200)
|
|
{
|
|
// 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;
|
|
QFile::remove(partPath);
|
|
}
|
|
else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206))
|
|
{
|
|
const qint64 actualSize = QFileInfo(partPath).size();
|
|
if ((expectedSize < 0 || actualSize == expectedSize)
|
|
&& calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
|
{
|
|
QFile::remove(savePath);
|
|
if (QFile::rename(partPath, savePath))
|
|
{
|
|
qDebug() << "Download completed/resumed & sha pass:" << savePath;
|
|
return true;
|
|
}
|
|
}
|
|
else if (expectedSize >= 0 && actualSize >= expectedSize)
|
|
{
|
|
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
|
|
QFile::remove(partPath);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
|
|
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
|
|
}
|
|
|
|
if (attempt < 3)
|
|
{
|
|
const unsigned long delayMs = static_cast<unsigned long>(500 * (1 << attempt));
|
|
QElapsedTimer delay;
|
|
delay.start();
|
|
while (delay.elapsed() < static_cast<qint64>(delayMs))
|
|
{
|
|
QApplication::processEvents();
|
|
QThread::msleep(50);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
|
{
|
|
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
|
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(":");
|
|
}
|
|
|
|
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
|
const QStringList& obsoletePaths) const
|
|
{
|
|
qint64 required = 0;
|
|
const QString cacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
|
|
for (const auto& item : m_fileItems) {
|
|
const QString relativePath = QDir::fromNativeSeparators(item.path);
|
|
if (isRuntimeProtectedPath(relativePath)) continue;
|
|
const QString installed = QDir(targetDir).filePath(relativePath);
|
|
if (QFile::exists(installed)
|
|
&& calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) == 0)
|
|
continue;
|
|
|
|
const qint64 partialSize = QFileInfo(QDir(cacheDir).filePath(
|
|
item.sha256.toLower() + ".part")).size();
|
|
required += qMax<qint64>(0, item.size - qMin(item.size, partialSize));
|
|
if (QFile::exists(installed)) required += QFileInfo(installed).size();
|
|
}
|
|
for (const QString& path : obsoletePaths) {
|
|
const QString installed = QDir(targetDir).filePath(path);
|
|
if (QFile::exists(installed)) required += QFileInfo(installed).size();
|
|
}
|
|
const qint64 reserve = qMax<qint64>(128LL * 1024 * 1024, required / 10);
|
|
return required + reserve;
|
|
}
|
|
|
|
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
|
|
{
|
|
if (m_fileItems.isEmpty())
|
|
{
|
|
m_downloadAllOk = false;
|
|
return false;
|
|
}
|
|
|
|
QDir stagingDir(tempDir);
|
|
if (!FileHelper::createDir(tempDir))
|
|
return false;
|
|
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
|
|
if (!FileHelper::createDir(resumeCacheDir))
|
|
return false;
|
|
QSet<QString> activePartialNames;
|
|
for (const auto& item : m_fileItems)
|
|
activePartialNames.insert(item.sha256.toLower() + ".part");
|
|
QDir cacheDir(resumeCacheDir);
|
|
for (const QString& fileName : cacheDir.entryList(QStringList() << "*.part", QDir::Files)) {
|
|
if (!activePartialNames.contains(fileName.toLower()))
|
|
cacheDir.remove(fileName);
|
|
}
|
|
|
|
m_downloadTotalBytes = 0;
|
|
m_downloadCompletedBytes = 0;
|
|
m_sessionDownloadedBytes = 0;
|
|
for (const auto& item : m_fileItems) {
|
|
const QString itemPath = QDir::fromNativeSeparators(item.path);
|
|
if (isRuntimeProtectedPath(itemPath)) continue;
|
|
const QString installed = QDir(targetDir).filePath(itemPath);
|
|
if (!QFile::exists(installed)
|
|
|| calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) != 0)
|
|
m_downloadTotalBytes += item.size;
|
|
}
|
|
m_downloadTimer.restart();
|
|
emit downloadProgress(0, m_downloadTotalBytes, QString(), 0.0);
|
|
|
|
for (const auto& fi : m_fileItems)
|
|
{
|
|
if (!isSafeRelativePath(fi.path))
|
|
{
|
|
qDebug() << "Unsafe relative path in manifest:" << fi.path;
|
|
m_downloadAllOk = false;
|
|
return false;
|
|
}
|
|
|
|
const QString relativePath = QDir::fromNativeSeparators(fi.path);
|
|
if (isRuntimeProtectedPath(relativePath)) {
|
|
qDebug() << "Runtime-protected file skipped:" << relativePath;
|
|
continue;
|
|
}
|
|
const QString installedFile = QDir(targetDir).filePath(relativePath);
|
|
if (QFile::exists(installedFile)
|
|
&& calcLocalFileSha256(installedFile).compare(fi.sha256, Qt::CaseInsensitive) == 0)
|
|
{
|
|
qDebug() << "Unchanged file, skip download:" << relativePath;
|
|
continue;
|
|
}
|
|
|
|
const QString tempFile = QDir(tempDir).filePath(relativePath);
|
|
const QString parentDir = QFileInfo(tempFile).path();
|
|
if (!QDir().mkpath(parentDir))
|
|
{
|
|
qDebug() << "Cannot create staging subdirectory:" << parentDir;
|
|
m_downloadAllOk = false;
|
|
return false;
|
|
}
|
|
|
|
const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part");
|
|
m_currentDownloadPath = relativePath;
|
|
const qint64 resumedBytes = qMin(fi.size, QFileInfo(resumePartPath).size());
|
|
const double speed = m_downloadTimer.elapsed() > 0
|
|
? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0;
|
|
emit downloadProgress(m_downloadCompletedBytes + resumedBytes,
|
|
m_downloadTotalBytes, m_currentDownloadPath, speed);
|
|
if (!downloadSingleFile(fi.url, tempFile, fi.sha256, fi.size, resumePartPath))
|
|
{
|
|
m_downloadAllOk = false;
|
|
return false;
|
|
}
|
|
m_downloadCompletedBytes += fi.size;
|
|
emit downloadProgress(m_downloadCompletedBytes, m_downloadTotalBytes,
|
|
m_currentDownloadPath, speed);
|
|
}
|
|
|
|
m_downloadAllOk = true;
|
|
return true;
|
|
}
|
|
|
|
void UpdaterLogic::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;
|
|
});
|
|
}
|
|
|
|
QList<FileDownloadItem> UpdaterLogic::getFileList() const
|
|
{
|
|
return m_fileItems;
|
|
}
|