fix: 优化客户端错误提示和校验诊断信息

This commit is contained in:
2026-07-21 03:02:42 +00:00
parent 1c8ec37d2b
commit d9e32c6cea
14 changed files with 1739 additions and 627 deletions
+1 -1
View File
@@ -228,7 +228,7 @@ bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetP
const QMessageBox::StandardButton choice = QMessageBox::question( const QMessageBox::StandardButton choice = QMessageBox::question(
nullptr, nullptr,
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"), QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
QCoreApplication::translate("ConfigHelper", "The current installation directory requires administrator permission to save configuration.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.") QCoreApplication::translate("ConfigHelper", "The current operation needs administrator permission to modify a protected file.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.")
.arg(QDir::toNativeSeparators(targetPath), originalError), .arg(QDir::toNativeSeparators(targetPath), originalError),
QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Ok | QMessageBox::Cancel,
QMessageBox::Ok); QMessageBox::Ok);
+25 -3
View File
@@ -257,12 +257,34 @@ bool DeviceIdentityHelper::ensureIssued(
timer.stop(); timer.stop();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString networkError = reply->errorString();
const QByteArray raw = reply->readAll(); const QByteArray raw = reply->readAll();
reply->deleteLater(); reply->deleteLater();
if (status != 200) { if (status != 200) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device identity request failed (HTTP %1): %2") QJsonParseError responseError;
.arg(status) const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError);
.arg(QString::fromUtf8(raw)); QString serverMessage;
if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) {
const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail"));
serverMessage = detail.isObject()
? detail.toObject().value(QStringLiteral("msg")).toString()
: detail.toString();
}
if (serverMessage.isEmpty())
serverMessage = QString::fromUtf8(raw).trimmed();
if (status == 0) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms")
.arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs));
} else {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5")
.arg(trimmedBaseUrl, QString::number(status), appId, channel,
serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "<empty response>") : serverMessage);
}
return false; return false;
} }
+100 -43
View File
@@ -5,8 +5,9 @@
#include <QDirIterator> #include <QDirIterator>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QSet> #include <QSet>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
@@ -58,19 +59,32 @@ QString IntegrityHelper::sha256(const QString& filePath) const
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; Q_UNUSED(payload); Q_UNUSED(signatureBase64);
#else m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
return false;
#else
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem"); QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
if (!QFile::exists(keyPath)) if (!QFile::exists(keyPath))
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem"); keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
QFile keyFile(keyPath); QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; } if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio); if (bio) BIO_free(bio);
if (!key) { m_error = "manifest public key invalid"; return false; } if (!key) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
@@ -78,10 +92,13 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; && EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key); EVP_PKEY_free(key);
if (!ok) m_error = "manifest RSA signature invalid"; if (!ok) {
return ok; m_error = QCoreApplication::translate("IntegrityHelper",
#endif "Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
} }
return ok;
#endif
}
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version) const QString& version)
@@ -96,42 +113,79 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
if (!QFile::exists(cachePath)) if (!QFile::exists(cachePath))
cachePath = legacyCachePath; cachePath = legacyCachePath;
QFile cache(cachePath); QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; } if (!cache.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is missing. Stage: installed version verification. Version: %1. Expected cache file: %2. This cache is created after the same version is published or installed successfully.")
.arg(version, cachePath);
return false;
}
QJsonParseError wrapperError; QJsonParseError wrapperError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = "manifest cache JSON invalid"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
} "Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, wrapperError.errorString());
return false;
}
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false; if (manifestText.isEmpty() || signature.isEmpty()) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath);
return false;
}
if (!verifySignature(manifestText, signature)) return false;
QJsonParseError manifestError; QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
m_error = "signed manifest payload invalid"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
} "Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, manifestError.errorString());
return false;
}
const QJsonObject manifest = manifestDoc.object(); const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel || manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) { || manifest.value("version").toString() != version) {
m_error = "manifest identity does not match local application"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
} "Local signed manifest identity does not match this application. Stage: installed version verification. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QSet<QString> declaredExecutables; QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) { for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject(); const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; } if (!safeRelativePath(path)) {
if (runtimeProtectedPath(path)) continue; m_error = QCoreApplication::translate("IntegrityHelper",
const QString fullPath = QDir(m_installDir).filePath(path); "Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; } .arg(version, path);
const QString expected = item.value("sha256").toString(); return false;
const QString actual = sha256(fullPath); }
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { if (runtimeProtectedPath(path)) continue;
m_error = "file hash mismatch: " + path; return false; const QString fullPath = QDir(m_installDir).filePath(path);
} if (!QFile::exists(fullPath)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"A required installed file is missing. Stage: installed version verification. Version: %1. Manifest path: %2. Checked path: %3. The local installation no longer matches the published version.")
.arg(version, path, fullPath);
return false;
}
const QString expected = item.value("sha256").toString();
const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected SHA-256: %4\nActual SHA-256: %5\nThis means the installed file is different from the version that was published or installed. If this is a developer test machine, check whether the local Release directory was recompiled or overwritten after publishing.")
.arg(version, path, fullPath, expected,
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
return false;
}
const QString suffix = QFileInfo(path).suffix().toCaseFolded(); const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
} }
@@ -150,10 +204,13 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|| folded.startsWith(runtimePrefix + "/update_temp/")); || folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/") if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue; || runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = "undeclared executable or plugin: " + relative; return false; m_error = QCoreApplication::translate("IntegrityHelper",
} "An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
return false;
}
} }
return true; return true;
} }
+33 -18
View File
@@ -1,5 +1,6 @@
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
@@ -29,30 +30,39 @@ bool LocalStateHelper::loadState(const QString& relativePath)
{"last_success_version", QString()} {"last_success_version", QString()}
}; };
m_loaded = true; m_loaded = true;
if (!saveState()) if (!saveState())
{ {
m_error = QString("Cannot write new state file: %1").arg(m_filePath); m_error = QCoreApplication::translate(
return false; "LocalStateHelper",
} "Cannot create local state file: %1. Error: %2.")
return true; .arg(m_filePath, m_error);
return false;
}
return true;
} }
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
{ {
m_error = QString("Cannot open state file: %1").arg(m_filePath); m_error = QCoreApplication::translate(
return false; "LocalStateHelper",
} "Cannot open local state file: %1. Error: %2.")
.arg(m_filePath, file.errorString());
return false;
}
QByteArray raw = file.readAll(); QByteArray raw = file.readAll();
file.close(); file.close();
QJsonParseError parseError; QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) if (parseError.error != QJsonParseError::NoError || !doc.isObject())
{ {
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString()); m_error = QCoreApplication::translate(
return false; "LocalStateHelper",
} "Local state file is not valid JSON. File: %1. JSON error: %2.")
.arg(m_filePath, parseError.errorString());
return false;
}
m_state = doc.object(); m_state = doc.object();
m_loaded = true; m_loaded = true;
@@ -63,7 +73,9 @@ bool LocalStateHelper::saveState() const
{ {
if (!m_loaded) if (!m_loaded)
{ {
m_error = "State is not loaded"; m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state has not been loaded.");
return false; return false;
} }
@@ -71,7 +83,10 @@ bool LocalStateHelper::saveState() const
QString writeError; QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError)) if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
{ {
m_error = QString("Cannot save state file: %1").arg(writeError); m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot save local state file: %1. Error: %2.")
.arg(m_filePath, writeError);
return false; return false;
} }
m_error.clear(); m_error.clear();
+79 -26
View File
@@ -1,6 +1,7 @@
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
@@ -29,15 +30,26 @@ static QString resolvePolicyPath(const QString& baseDir, const QString& relative
bool PolicyHelper::loadPolicy(const QString& relativePath) bool PolicyHelper::loadPolicy(const QString& relativePath)
{ {
QFile file(resolvePolicyPath(m_baseDir, relativePath, false)); const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; } QFile file(path);
QJsonParseError error; if (!file.open(QIODevice::ReadOnly)) {
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); m_error = QCoreApplication::translate(
if (error.error != QJsonParseError::NoError || !doc.isObject()) { "PolicyHelper",
m_error = "Invalid policy JSON: " + error.errorString(); return false; "Cannot open signed version policy file: %1. Error: %2.")
} .arg(path, file.errorString());
return loadPolicyObject(doc.object()); return false;
} }
QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
.arg(path, error.errorString());
return false;
}
return loadPolicyObject(doc.object());
}
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText) bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
{ {
@@ -51,7 +63,10 @@ bool PolicyHelper::savePolicy(const QString& relativePath) const
QString writeError; QString writeError;
const QString path = resolvePolicyPath(m_baseDir, relativePath, true); const QString path = resolvePolicyPath(m_baseDir, relativePath, true);
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) { if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) {
m_error = "Cannot save policy file: " + writeError; m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot save signed version policy file: %1. Error: %2.")
.arg(path, writeError);
return false; return false;
} }
m_error.clear(); m_error.clear();
@@ -84,35 +99,73 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; Q_UNUSED(payload);
#else Q_UNUSED(signatureBase64);
QString keyPath = m_baseDir + "/config/manifest_public_key.pem"; m_error = QCoreApplication::translate(
QFile keyFile(keyPath); "PolicyHelper",
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; } "OpenSSL is unavailable, so the signed version policy cannot be verified.");
return false;
#else
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open version policy public key: %1. Error: %2.")
.arg(keyPath, keyFile.errorString());
return false;
}
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio); if (bio) BIO_free(bio);
if (!key) { m_error = "Invalid policy public key"; return false; } if (!key) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy public key is invalid: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; && EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) m_error = "Invalid RSA policy signature"; if (!ok) {
return ok; m_error = QCoreApplication::translate(
#endif "PolicyHelper",
} "Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
}
return ok;
#endif
}
bool PolicyHelper::isValid() const bool PolicyHelper::isValid() const
{ {
if (!m_loaded) return false; if (!m_loaded) {
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run", m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"}; return false;
for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; } }
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false; const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
for (const QString& key : required) {
if (!m_policy.contains(key)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy is missing required field: %1.")
.arg(key);
return false;
}
}
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy signature algorithm is unsupported: %1.")
.arg(m_policy.value("signature_alg").toString());
return false;
}
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString()); return verifySignature(payload, m_policy.value("signature").toString());
} }
+106 -36
View File
@@ -5,10 +5,11 @@
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QMessageAuthenticationCode> #include <QMessageAuthenticationCode>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
#include <QUuid> #include <QStringList>
#include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
{ {
@@ -22,10 +23,20 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
{ {
// Launcher 启动业务主程序前生成一次性 ticket。 // Launcher 启动业务主程序前生成一次性 ticket。
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。 // ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) { if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "ticket identity or secret is empty"; if (errorMessage) {
return false; QStringList missing;
} if (appId.isEmpty()) missing.append(QStringLiteral("app_id"));
if (deviceId.isEmpty()) missing.append(QStringLiteral("device_id"));
if (version.isEmpty()) missing.append(QStringLiteral("current_version"));
if (secret.isEmpty()) missing.append(QStringLiteral("launch_token"));
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket because required fields are empty: %1. Check app_config.json, registry-imported configuration and device authorization.")
.arg(missing.join(QStringLiteral(", ")));
}
return false;
}
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
QJsonObject payload{ QJsonObject payload{
{"app_id", appId}, {"device_id", deviceId}, {"version", version}, {"app_id", appId}, {"device_id", deviceId}, {"version", version},
@@ -36,14 +47,27 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
}; };
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}}; QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets"); const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; } if (!QDir().mkpath(dirPath)) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket directory: %1.")
.arg(dirPath);
}
return false;
}
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
QSaveFile file(path); QSaveFile file(path);
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
if (errorMessage) *errorMessage = "cannot save ticket"; if (errorMessage) {
return false; *errorMessage = QCoreApplication::translate(
} "TicketHelper",
"Cannot save launch ticket file: %1. Error: %2.")
.arg(path, file.errorString());
}
return false;
}
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
if (ticketPath) *ticketPath = path; if (ticketPath) *ticketPath = path;
return true; return true;
@@ -56,24 +80,40 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。 // 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。 // 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
const QString consumingPath = ticketPath + ".consuming." const QString consumingPath = ticketPath + ".consuming."
+ QString::number(QCoreApplication::applicationPid()); + QString::number(QCoreApplication::applicationPid());
if (!QFile::rename(ticketPath, consumingPath)) { if (!QFile::rename(ticketPath, consumingPath)) {
if (errorMessage) *errorMessage = "ticket missing or already consumed"; if (errorMessage) {
return false; *errorMessage = QCoreApplication::translate(
} "TicketHelper",
QFile file(consumingPath); "Launch ticket is missing or has already been consumed. Ticket file: %1. Please start the application from Launcher.")
if (!file.open(QIODevice::ReadOnly)) { .arg(ticketPath);
QFile::remove(consumingPath); }
if (errorMessage) *errorMessage = "cannot read claimed ticket"; return false;
return false; }
} QFile file(consumingPath);
if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(consumingPath);
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot read claimed launch ticket: %1. Error: %2.")
.arg(consumingPath, file.errorString());
}
return false;
}
const QByteArray raw = file.readAll(); file.close(); const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure. QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure.
QJsonParseError parseError; QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false; if (errorMessage) {
} *errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is not valid JSON. JSON error: %1.")
.arg(parseError.errorString());
}
return false;
}
const QJsonObject wrapper = doc.object(); const QJsonObject wrapper = doc.object();
const QJsonObject payload = wrapper.value("payload").toObject(); const QJsonObject payload = wrapper.value("payload").toObject();
const QByteArray actual = wrapper.value("signature").toString().toLatin1(); const QByteArray actual = wrapper.value("signature").toString().toLatin1();
@@ -84,27 +124,57 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5) const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
&& expires >= now && issued.secsTo(expires) <= 65; && expires >= now && issued.secsTo(expires) <= 65;
if (actual.isEmpty() || actual != expected) { if (actual.isEmpty() || actual != expected) {
if (errorMessage) *errorMessage = "ticket signature invalid; check launch_token"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket signature is invalid. The launch_token used by Launcher and the main application is inconsistent, or the ticket content was changed.");
}
return false; return false;
} }
if (payload.value("app_id").toString() != expectedAppId) { if (payload.value("app_id").toString() != expectedAppId) {
if (errorMessage) *errorMessage = "ticket app_id mismatch"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket app_id does not match. Ticket app_id: %1. Expected app_id: %2.")
.arg(payload.value("app_id").toString(), expectedAppId);
}
return false; return false;
} }
if (payload.value("device_id").toString() != expectedDeviceId) { if (payload.value("device_id").toString() != expectedDeviceId) {
if (errorMessage) *errorMessage = "ticket device_id mismatch"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket device_id does not match. Ticket device_id: %1. Expected device_id: %2. Reauthorize the device from Launcher if the configuration was regenerated.")
.arg(payload.value("device_id").toString(), expectedDeviceId);
}
return false; return false;
} }
if (payload.value("version").toString() != expectedVersion) { if (payload.value("version").toString() != expectedVersion) {
if (errorMessage) *errorMessage = "ticket version mismatch"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket version does not match. Ticket version: %1. Expected version: %2.")
.arg(payload.value("version").toString(), expectedVersion);
}
return false; return false;
} }
if (!timeOk) { if (!timeOk) {
if (errorMessage) *errorMessage = "ticket time invalid or expired"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket time is invalid or expired. Issued at: %1. Expires at: %2. Current UTC time: %3.")
.arg(payload.value("issued_at").toString(),
payload.value("expires_at").toString(),
now.toString(Qt::ISODate));
}
return false; return false;
} }
if (payload.value("nonce").toString().isEmpty()) { if (payload.value("nonce").toString().isEmpty()) {
if (errorMessage) *errorMessage = "ticket nonce missing"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket nonce is missing. The ticket is incomplete.");
}
return false; return false;
} }
return true; return true;
+52 -32
View File
@@ -104,11 +104,13 @@ void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, c
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version, bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir) int versionId, const QString& cacheDir)
{ {
m_error.clear(); m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) { if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
m_error = "manifest identity is incomplete"; m_error = QCoreApplication::translate("UpdateLogic",
return false; "Current version manifest identity is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.")
} .arg(appId, channel, version, QString::number(versionId));
return false;
}
QJsonObject response; QJsonObject response;
int statusCode = 0; int statusCode = 0;
@@ -122,40 +124,58 @@ bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, co
statusCode = code; statusCode = code;
response = resp; response = resp;
}); });
if (statusCode != 200) {
const QJsonValue detail = response.value(QStringLiteral("detail"));
const QString message = detail.isObject()
? detail.toObject().value(QStringLiteral("msg")).toString()
: detail.toString();
m_error = QCoreApplication::translate("UpdateLogic",
"Cannot download signed manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
.arg(QString::number(statusCode), appId, channel, version, QString::number(versionId),
message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
return false;
}
if (statusCode != 200) { const QJsonObject manifest = response.value("manifest").toObject();
m_error = QString("manifest request failed (HTTP %1)").arg(statusCode); const QString manifestText = response.value("manifest_text").toString();
return false; if (manifest.isEmpty() || manifestText.isEmpty()) {
} m_error = QCoreApplication::translate("UpdateLogic",
"The signed manifest response for the current local version is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.")
const QJsonObject manifest = response.value("manifest").toObject(); .arg(appId, channel, version, QString::number(versionId));
const QString manifestText = response.value("manifest_text").toString(); return false;
if (manifest.isEmpty() || manifestText.isEmpty()) { }
m_error = "manifest response is incomplete";
return false;
}
if (manifest.value("app_id").toString() != appId if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel || manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) { || manifest.value("version").toString() != version) {
m_error = "manifest identity does not match current version"; m_error = QCoreApplication::translate("UpdateLogic",
return false; "The signed manifest identity does not match the current local version. Stage: cache current version manifest. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
} .arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QDir dir(cacheDir); QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) { if (!dir.exists() && !dir.mkpath(".")) {
m_error = "cannot create manifest cache directory"; m_error = QCoreApplication::translate("UpdateLogic",
return false; "Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.")
} .arg(cacheDir);
return false;
}
QJsonObject wrapper; QJsonObject wrapper;
wrapper["manifest"] = manifest; wrapper["manifest"] = manifest;
wrapper["manifest_text"] = manifestText; wrapper["manifest_text"] = manifestText;
QSaveFile file(dir.filePath("manifest_" + version + ".json")); QSaveFile file(dir.filePath("manifest_" + version + ".json"));
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented); const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
m_error = "cannot save signed manifest cache"; m_error = QCoreApplication::translate("UpdateLogic",
return false; "Cannot save signed manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
} .arg(file.fileName(), file.errorString());
return false;
}
qDebug() << "Current version manifest cached to" << file.fileName(); qDebug() << "Current version manifest cached to" << file.fileName();
return true; return true;
} }
+82 -26
View File
@@ -176,9 +176,29 @@ int main(int argc, char* argv[])
QCoreApplication::translate("Launcher", "Select Offline Update Package"), QCoreApplication::translate("Launcher", "Select Offline Update Package"),
QString(), QString(),
QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)")); QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)"));
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)}); if (package.isEmpty())
return false;
if (!QFileInfo::exists(updaterPath)) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot import the offline update package because the updater executable does not exist.\nUpdater: %1\nOffline package: %2")
.arg(updaterPath, package));
return false;
}
if (!QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)})) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater for offline package import.\nUpdater: %1\nOffline package: %2\nCheck file permissions and dependent DLLs/shared libraries.")
.arg(updaterPath, package));
return false;
}
return true;
}; };
const QString deviceId = config.getValue("Update", "device_id"); const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) { if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close(); progress.close();
if (!importOfflinePackage()) if (!importOfflinePackage())
@@ -188,21 +208,40 @@ int main(int argc, char* argv[])
return 0; return 0;
} }
QString mainStartupError;
const auto startMainApp = [&]() { const auto startMainApp = [&]() {
// 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。 // 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。
// 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。 // 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) { launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
return false; mainStartupError = QCoreApplication::translate(
} "Launcher",
const bool started = QProcess::startDetached(mainAppPath, "Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
QStringList{QString("--ticket-file=%1").arg(ticketPath)}); .arg(mainAppPath, ticketError);
if (!started) QFile::remove(ticketPath); return false;
return started; }
}; const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) {
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath);
}
return started;
};
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy...")); progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy..."));
QApplication::processEvents(); QApplication::processEvents();
@@ -294,23 +333,38 @@ int main(int argc, char* argv[])
accepted = QMessageBox::question(nullptr, dialogTitle, prompt, accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
} }
if (!accepted) { if (!accepted) {
if (!startMainApp()) { if (!startMainApp()) {
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"), QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)); mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1; return -1;
} }
return 0; return 0;
} }
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
if (!QFileInfo::exists(updaterPath))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater because the executable file does not exist.\nExecutable: %1\nCheck updater_executable and install_root in the generated client configuration.")
.arg(updaterPath));
return -1;
}
if (!QProcess::startDetached(updaterPath, updaterArgs)) if (!QProcess::startDetached(updaterPath, updaterArgs))
{ {
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"), QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath)); QCoreApplication::translate(
"Launcher",
"Cannot start the updater process.\nExecutable: %1\nArguments: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the updater can run independently.")
.arg(updaterPath, updaterArgs.join(QStringLiteral(" "))));
return -1; return -1;
} }
return 0; return 0;
} }
@@ -357,14 +411,16 @@ int main(int argc, char* argv[])
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...") ? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting...")); : QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
QApplication::processEvents(); QApplication::processEvents();
if (!startMainApp()) if (!startMainApp())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"), QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)); mainStartupError.isEmpty()
return -1; ? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
} : mainStartupError);
return -1;
}
progress.close(); progress.close();
return 0; return 0;
} }
+8 -6
View File
@@ -97,12 +97,14 @@ int main(int argc, char* argv[])
if (!integrity.verifyInstalledVersion( if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"), config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version"))) config.getValue("App", "current_version")))
{ {
QMessageBox::critical(nullptr, "Integrity Check Failed", QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Application files failed signed Manifest verification:\n%1") QString("Startup blocked because the installed files failed local signed Manifest verification.\n"
.arg(integrity.errorString())); "This is not a download check; it means the current installation directory does not match the signed Manifest cache for this version.\n\n"
return -1; "Details:\n%1")
} .arg(integrity.errorString()));
return -1;
}
MainWindow w; MainWindow w;
w.show(); w.show();
+367 -209
View File
@@ -25,16 +25,32 @@
#include <openssl/err.h> #include <openssl/err.h>
#endif #endif
UpdaterLogic::UpdaterLogic(QObject* parent) UpdaterLogic::UpdaterLogic(QObject* parent)
: QObject(parent) : QObject(parent)
{ {
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url"); m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
} }
namespace {
QString serverDetailMessage(const QJsonObject& response)
{
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject obj = detail.toObject();
const QString msg = obj.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty()) return msg;
const QString error = obj.value(QStringLiteral("error")).toString();
if (!error.isEmpty()) return error;
}
return detail.toString();
}
}
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId) void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{ {
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。 // Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。 // Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
m_error.clear();
QString url = m_serverAddr + "/api/v1/update/manifest"; QString url = m_serverAddr + "/api/v1/update/manifest";
QJsonObject body; QJsonObject body;
body["app_id"] = appId; body["app_id"] = appId;
@@ -42,7 +58,7 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
body["version"] = targetVer; body["version"] = targetVer;
body["version_id"] = versionId; 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; qDebug() << "Manifest API returned code:" << code;
m_manifest = QJsonObject(); m_manifest = QJsonObject();
@@ -68,61 +84,82 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
m_fileItems.append(fi); m_fileItems.append(fi);
} }
} }
else else
{ {
qDebug() << "Manifest response missing fields"; 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.")
else .arg(appId, channel, targetVer, QString::number(versionId));
{ }
qDebug() << "Failed to get manifest"; }
} else
emit fetchUrlFinished(); {
}); 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),
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
}
emit fetchUrlFinished();
});
}
bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(payload);
Q_UNUSED(signatureBase64); Q_UNUSED(signatureBase64);
Q_UNUSED(publicKeyPath); Q_UNUSED(publicKeyPath);
qDebug() << "OpenSSL not available, cannot verify manifest signature"; qDebug() << "OpenSSL not available, cannot verify manifest signature";
return false; m_error = QCoreApplication::translate("UpdaterLogic",
#else "Cannot verify manifest signature because OpenSSL support is unavailable. Stage: manifest signature verification.");
QFile keyFile(publicKeyPath); return false;
if (!keyFile.open(QIODevice::ReadOnly)) #else
{ QFile keyFile(publicKeyPath);
qDebug() << "Cannot open public key file:" << publicKeyPath; if (!keyFile.open(QIODevice::ReadOnly))
return false; {
} qDebug() << "Cannot open public key file:" << publicKeyPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open manifest public key. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false;
}
QByteArray keyData = keyFile.readAll(); QByteArray keyData = keyFile.readAll();
keyFile.close(); keyFile.close();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
if (!bio) if (!bio)
{ {
qDebug() << "BIO_new_mem_buf failed"; qDebug() << "BIO_new_mem_buf failed";
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "Cannot parse manifest public key buffer. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false;
}
EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL);
BIO_free(bio); BIO_free(bio);
if (!pkey) if (!pkey)
{ {
qDebug() << "PEM_read_bio_PUBKEY failed"; qDebug() << "PEM_read_bio_PUBKEY failed";
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "Manifest public key is invalid. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false;
}
QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
if (!mdctx) if (!mdctx)
{ {
EVP_PKEY_free(pkey); EVP_PKEY_free(pkey);
qDebug() << "EVP_MD_CTX_new failed"; qDebug() << "EVP_MD_CTX_new failed";
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "Cannot create OpenSSL verification context. Stage: manifest signature verification.");
return false;
}
bool ok = false; bool ok = false;
if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1) if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1)
@@ -139,29 +176,35 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
EVP_MD_CTX_free(mdctx); EVP_MD_CTX_free(mdctx);
EVP_PKEY_free(pkey); EVP_PKEY_free(pkey);
if (!ok) if (!ok)
{ {
qDebug() << "Manifest signature verification failed"; qDebug() << "Manifest signature verification failed";
} m_error = QCoreApplication::translate("UpdaterLogic",
return ok; "Manifest RSA signature is invalid. Stage: manifest signature verification. This usually means the manifest was not signed by the matching server private key, the client public key is wrong, or the manifest content was changed.");
#endif }
} return ok;
#endif
}
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
{ {
// 验签用的是客户端随 SDK 分发的公钥。 // 验签用的是客户端随 SDK 分发的公钥。
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。 // 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
if (m_manifest.isEmpty() || m_manifestText.isEmpty()) if (m_manifest.isEmpty() || m_manifestText.isEmpty())
{ {
qDebug() << "No manifest available to verify"; qDebug() << "No manifest available to verify";
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "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(); QString signature = m_manifest.value("signature").toString();
if (signature.isEmpty()) if (signature.isEmpty())
{ {
qDebug() << "Manifest signature empty"; qDebug() << "Manifest signature empty";
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "The manifest does not contain a signature. Stage: manifest signature verification.");
return false;
}
QString path = publicKeyPath; QString path = publicKeyPath;
if (!QFile::exists(path)) if (!QFile::exists(path))
@@ -175,18 +218,29 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
{ {
// 启动后的完整性检查依赖本地 Manifest 缓存。 // 启动后的完整性检查依赖本地 Manifest 缓存。
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。 // 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
if (m_manifest.isEmpty()) if (m_manifest.isEmpty()) {
return false; m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot save manifest cache because the target manifest is empty. Stage: save target manifest cache.");
QDir dir(cacheDir); return false;
if (!dir.exists() && !dir.mkpath(".")) }
return false;
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create manifest cache directory. Stage: save target manifest cache. Directory: %1.")
.arg(cacheDir);
return false;
}
QString version = m_manifest.value("version").toString(); QString version = m_manifest.value("version").toString();
QString filePath = cacheDir + "/manifest_" + version + ".json"; QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath); QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
return false; m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot write manifest cache file. Stage: save target manifest cache. File: %1. Error: %2.")
.arg(filePath, file.errorString());
return false;
}
QJsonObject wrapper; QJsonObject wrapper;
wrapper["manifest"] = m_manifest; wrapper["manifest"] = m_manifest;
@@ -194,33 +248,48 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
QJsonDocument doc(wrapper); QJsonDocument doc(wrapper);
file.write(doc.toJson(QJsonDocument::Indented)); file.write(doc.toJson(QJsonDocument::Indented));
file.close(); file.close();
qDebug() << "Manifest cached to" << filePath; qDebug() << "Manifest cached to" << filePath;
return true; m_error.clear();
} return true;
}
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
{ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
QString filePath = cacheDir + "/manifest_" + version + ".json"; {
QFile file(filePath); m_error.clear();
if (!file.exists() || !file.open(QIODevice::ReadOnly)) QString filePath = cacheDir + "/manifest_" + version + ".json";
return false; QFile file(filePath);
if (!file.exists() || !file.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot read cached signed manifest. Stage: read local manifest cache. Version: %1. File: %2. This cache is created after a version is installed successfully.")
.arg(version, filePath);
return false;
}
QByteArray raw = file.readAll(); QByteArray raw = file.readAll();
file.close(); file.close();
QJsonDocument doc = QJsonDocument::fromJson(raw); QJsonDocument doc = QJsonDocument::fromJson(raw);
if (!doc.isObject()) if (!doc.isObject()) {
return false; m_error = QCoreApplication::translate("UpdaterLogic",
"Cached signed manifest is not valid JSON. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath);
return false;
}
QJsonObject wrapper = doc.object();
if (!wrapper.contains("manifest") || !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;
}
QJsonObject wrapper = doc.object(); m_manifest = wrapper["manifest"].toObject();
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) m_manifestText = wrapper["manifest_text"].toString();
return false; qDebug() << "Loaded cached manifest" << version;
m_error.clear();
m_manifest = wrapper["manifest"].toObject(); return true;
m_manifestText = wrapper["manifest_text"].toString(); }
qDebug() << "Loaded cached manifest" << version;
return true;
}
QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const
{ {
@@ -311,43 +380,63 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
return obsolete; return obsolete;
} }
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
{ {
if (m_manifest.isEmpty()) m_error.clear();
return false; const QString stage = installedDir.isEmpty()
? QCoreApplication::translate("UpdaterLogic", "installed version verification")
const QJsonArray files = m_manifest.value("files").toArray(); : QCoreApplication::translate("UpdaterLogic", "downloaded/staged file verification");
for (const auto& item : files) const QString version = m_manifest.value(QStringLiteral("version")).toString();
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available. Stage: %1. The updater cannot know which files and hashes should be verified.")
.arg(stage);
return false;
}
const QJsonArray files = m_manifest.value("files").toArray();
for (const auto& item : files)
{ {
const QJsonObject fileObject = item.toObject(); const QJsonObject fileObject = item.toObject();
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString()); const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
const QString expectedSha = fileObject.value("sha256").toString(); const QString expectedSha = fileObject.value("sha256").toString();
if (!isSafeRelativePath(path)) if (!isSafeRelativePath(path)) {
return false; m_error = QCoreApplication::translate("UpdaterLogic",
if (isRuntimeProtectedPath(path)) { "Manifest contains an unsafe file path. Stage: %1. Version: %2. Path: %3.")
qDebug() << "Runtime-protected manifest entry ignored:" << path; .arg(stage, version, path);
continue; return false;
}
if (isRuntimeProtectedPath(path)) {
qDebug() << "Runtime-protected manifest entry ignored:" << path;
continue;
} }
QString fullPath = QDir(stagingDir).filePath(path); QString fullPath = QDir(stagingDir).filePath(path);
if (!QFile::exists(fullPath) && !installedDir.isEmpty()) if (!QFile::exists(fullPath) && !installedDir.isEmpty())
fullPath = QDir(installedDir).filePath(path); fullPath = QDir(installedDir).filePath(path);
if (!QFile::exists(fullPath)) if (!QFile::exists(fullPath))
{ {
qDebug() << "Manifest file missing from staging and installation:" << path; qDebug() << "Manifest file missing from staging and installation:" << path;
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "A required file is missing. Stage: %1. Version: %2. Manifest path: %3. Checked path: %4. If this is a downloaded update, the file was not downloaded or staged correctly; if this is startup verification, the installed file may have been deleted.")
const QString actualSha = calcLocalFileSha256(fullPath); .arg(stage, version, path, fullPath);
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) return false;
{ }
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha; const QString actualSha = calcLocalFileSha256(fullPath);
return false; if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
} {
} qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
qDebug() << "Full manifest validation passed using staging plus installed files"; m_error = QCoreApplication::translate("UpdaterLogic",
return true; "File SHA-256 does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected SHA-256: %5\nActual SHA-256: %6\nIf this happens during download, clear the update cache and retry. If this happens during startup or after installation, the local file differs from the published version.")
} .arg(stage, version, path, fullPath, expectedSha, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
return false;
}
}
qDebug() << "Full manifest validation passed using staging plus installed files";
m_error.clear();
return true;
}
bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir) bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir)
{ {
@@ -395,9 +484,10 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
return true; return true;
} }
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{ {
QString url = m_serverAddr + "/api/v1/update/download-url"; m_error.clear();
QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body; QJsonObject body;
body["app_id"] = appId; body["app_id"] = appId;
body["channel"] = channel; body["channel"] = channel;
@@ -407,7 +497,7 @@ void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel,
QJsonArray emptyFiles; QJsonArray emptyFiles;
body["files"] = emptyFiles; body["files"] = emptyFiles;
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() << "Download URL API returned code:" << code; qDebug() << "Download URL API returned code:" << code;
m_fileItems.clear(); m_fileItems.clear();
@@ -427,13 +517,18 @@ void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel,
qDebug() << "File info:" << fi.path << fi.url << fi.sha256; qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
} }
} }
else else
{ {
qDebug() << "Failed to get download URL"; qDebug() << "Failed to get download URL";
} const QString detail = serverDetailMessage(resp);
emit fetchUrlFinished(); 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();
});
}
QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const
@@ -454,13 +549,21 @@ QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const
bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath, bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath,
const QString& expectSha256, qint64 expectedSize, const QString& expectSha256, qint64 expectedSize,
const QString& resumePartPath) const QString& resumePartPath)
{ {
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath; const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
if (!QDir().mkpath(QFileInfo(partPath).path())) return false; const QString displayPath = m_currentDownloadPath.isEmpty() ? savePath : m_currentDownloadPath;
if (QFile::exists(savePath) if (!QDir().mkpath(QFileInfo(partPath).path())) {
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) m_error = QCoreApplication::translate("UpdaterLogic",
return true; "Cannot create partial download directory. Stage: download file. File: %1. Partial file: %2.")
QFile::remove(savePath); .arg(displayPath, partPath);
return false;
}
if (QFile::exists(savePath)
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) {
m_error.clear();
return true;
}
QFile::remove(savePath);
for (int attempt = 0; attempt < 4; ++attempt) for (int attempt = 0; attempt < 4; ++attempt)
{ {
@@ -472,23 +575,37 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
} }
if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0) if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0)
{ {
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{ {
QFile::remove(savePath); QFile::remove(savePath);
return QFile::rename(partPath, savePath); if (QFile::rename(partPath, savePath)) {
} m_error.clear();
QFile::remove(partPath); return true;
existingSize = 0; }
} m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
}
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached partial file has the expected size but wrong SHA-256. Stage: resume download. File: %1.\nExpected SHA-256: %2\nActual SHA-256: %3\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, expectSha256, actualSha);
QFile::remove(partPath);
existingSize = 0;
}
QFile partFile(partPath); QFile partFile(partPath);
const QIODevice::OpenMode mode = QIODevice::WriteOnly const QIODevice::OpenMode mode = QIODevice::WriteOnly
| (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate); | (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate);
if (!partFile.open(mode)) if (!partFile.open(mode))
{ {
qDebug() << "Cannot open partial download:" << partPath; qDebug() << "Cannot open partial download:" << partPath;
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "Cannot open partial download file. Stage: download file. File: %1. Partial file: %2. Error: %3.")
.arg(displayPath, partPath, partFile.errorString());
return false;
}
QNetworkAccessManager manager; QNetworkAccessManager manager;
manager.setProxy(QNetworkProxy::NoProxy); manager.setProxy(QNetworkProxy::NoProxy);
@@ -533,27 +650,45 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206)) else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206))
{ {
const qint64 actualSize = QFileInfo(partPath).size(); const qint64 actualSize = QFileInfo(partPath).size();
if ((expectedSize < 0 || actualSize == expectedSize) if ((expectedSize < 0 || actualSize == expectedSize)
&& calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) && calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{ {
QFile::remove(savePath); QFile::remove(savePath);
if (QFile::rename(partPath, savePath)) if (QFile::rename(partPath, savePath))
{ {
qDebug() << "Download completed/resumed & sha pass:" << savePath; qDebug() << "Download completed/resumed & sha pass:" << savePath;
return true; m_error.clear();
} return true;
} }
else if (expectedSize >= 0 && actualSize >= expectedSize) m_error = QCoreApplication::translate("UpdaterLogic",
{ "Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath; .arg(displayPath, partPath, savePath);
QFile::remove(partPath); return false;
} }
} else if (expectedSize >= 0 && actualSize >= expectedSize)
else {
{ qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1 const QString actualSha = calcLocalFileSha256(partPath);
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size(); m_error = QCoreApplication::translate("UpdaterLogic",
} "Downloaded file does not match the signed manifest. Stage: download file. File: %1. HTTP status: %2.\nExpected size: %3 bytes\nActual size: %4 bytes\nExpected SHA-256: %5\nActual SHA-256: %6\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize),
expectSha256, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
QFile::remove(partPath);
}
else {
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file is incomplete. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes, current size: %4 bytes.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize));
}
}
else
{
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
m_error = QCoreApplication::translate("UpdaterLogic",
"Download request failed. Stage: download file. File: %1. Attempt: %2/4. HTTP status: %3. Network error: %4. Partial file: %5.")
.arg(displayPath, QString::number(attempt + 1), QString::number(httpStatus), networkError, partPath);
}
if (attempt < 3) if (attempt < 3)
{ {
@@ -566,9 +701,14 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
QThread::msleep(50); QThread::msleep(50);
} }
} }
} }
return false; if (m_error.isEmpty()) {
} m_error = QCoreApplication::translate("UpdaterLogic",
"File download failed after retries. Stage: download file. File: %1.")
.arg(displayPath);
}
return false;
}
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{ {
@@ -632,20 +772,31 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
return required + reserve; return required + reserve;
} }
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir) bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
{ {
if (m_fileItems.isEmpty()) m_error.clear();
{ if (m_fileItems.isEmpty())
m_downloadAllOk = false; {
return false; m_downloadAllOk = false;
} m_error = QCoreApplication::translate("UpdaterLogic",
"The target version manifest contains no downloadable files. Stage: prepare downloads.");
QDir stagingDir(tempDir); return false;
if (!FileHelper::createDir(tempDir)) }
return false;
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); QDir stagingDir(tempDir);
if (!FileHelper::createDir(resumeCacheDir)) if (!FileHelper::createDir(tempDir)) {
return false; m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create update staging directory. Stage: prepare downloads. Directory: %1.")
.arg(tempDir);
return false;
}
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
if (!FileHelper::createDir(resumeCacheDir)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create download cache directory. Stage: prepare downloads. Directory: %1.")
.arg(resumeCacheDir);
return false;
}
QSet<QString> activePartialNames; QSet<QString> activePartialNames;
for (const auto& item : m_fileItems) for (const auto& item : m_fileItems)
activePartialNames.insert(item.sha256.toLower() + ".part"); activePartialNames.insert(item.sha256.toLower() + ".part");
@@ -671,12 +822,15 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
for (const auto& fi : m_fileItems) for (const auto& fi : m_fileItems)
{ {
if (!isSafeRelativePath(fi.path)) if (!isSafeRelativePath(fi.path))
{ {
qDebug() << "Unsafe relative path in manifest:" << fi.path; qDebug() << "Unsafe relative path in manifest:" << fi.path;
m_downloadAllOk = false; m_downloadAllOk = false;
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "Manifest contains an unsafe file path. Stage: prepare file download. Path: %1.")
.arg(fi.path);
return false;
}
const QString relativePath = QDir::fromNativeSeparators(fi.path); const QString relativePath = QDir::fromNativeSeparators(fi.path);
if (isRuntimeProtectedPath(relativePath)) { if (isRuntimeProtectedPath(relativePath)) {
@@ -693,12 +847,15 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
const QString tempFile = QDir(tempDir).filePath(relativePath); const QString tempFile = QDir(tempDir).filePath(relativePath);
const QString parentDir = QFileInfo(tempFile).path(); const QString parentDir = QFileInfo(tempFile).path();
if (!QDir().mkpath(parentDir)) if (!QDir().mkpath(parentDir))
{ {
qDebug() << "Cannot create staging subdirectory:" << parentDir; qDebug() << "Cannot create staging subdirectory:" << parentDir;
m_downloadAllOk = false; m_downloadAllOk = false;
return false; m_error = QCoreApplication::translate("UpdaterLogic",
} "Cannot create staging subdirectory. Stage: prepare file download. File: %1. Directory: %2.")
.arg(relativePath, parentDir);
return false;
}
const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part"); const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part");
m_currentDownloadPath = relativePath; m_currentDownloadPath = relativePath;
@@ -717,9 +874,10 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
m_currentDownloadPath, speed); m_currentDownloadPath, speed);
} }
m_downloadAllOk = true; m_downloadAllOk = true;
return true; m_error.clear();
} return true;
}
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
const QString& version, bool success) const QString& version, bool success)
+9 -7
View File
@@ -29,8 +29,9 @@ public:
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const; bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version); bool loadManifestCache(const QString& cacheDir, const QString& version);
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString()); bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
QString offlineError() const { return m_offlineError; } QString offlineError() const { return m_offlineError; }
QString errorString() const { return m_error; }
void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId); void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
@@ -63,11 +64,12 @@ private:
QJsonObject m_manifest; QJsonObject m_manifest;
QString m_manifestText; QString m_manifestText;
QList<FileDownloadItem> m_fileItems; QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false; bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0; qint64 m_downloadTotalBytes = 0;
qint64 m_downloadCompletedBytes = 0; qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0; qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath; QString m_currentDownloadPath;
QString m_offlineError; QString m_offlineError;
QElapsedTimer m_downloadTimer; mutable QString m_error;
}; QElapsedTimer m_downloadTimer;
};
+76 -40
View File
@@ -3,9 +3,10 @@
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QMessageBox> #include <QFileInfo>
#include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QSaveFile> #include <QSaveFile>
@@ -164,14 +165,19 @@ int main(int argc, char* argv[])
if (offlinePackagePath.isEmpty()) if (offlinePackagePath.isEmpty())
logic.reportResult(deviceId, fromVersion, targetVersion, success); logic.reportResult(deviceId, fromVersion, targetVersion, success);
}; };
const auto fail = [&](const QString& title, const QString& message, const auto fail = [&](const QString& title, const QString& message,
const QString& errorCode = QString("update_failed")) { const QString& errorCode = QString("update_failed")) {
transaction.markFailed(errorCode, message); transaction.markFailed(errorCode, message);
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, title, message); QMessageBox::critical(nullptr, title, message);
return -1; return -1;
}; };
const auto withDetails = [](const QString& message, const QString& details) {
return details.trimmed().isEmpty()
? message
: message + QCoreApplication::translate("Updater", "\n\nDetails:\n%1").arg(details);
};
const auto configuredName = [&](const QString& key, const QString& fallback) { const auto configuredName = [&](const QString& key, const QString& fallback) {
return ConfigHelper::executableNameForCurrentPlatform( return ConfigHelper::executableNameForCurrentPlatform(
config.getValue("Runtime", key), fallback); config.getValue("Runtime", key), fallback);
@@ -182,25 +188,44 @@ int main(int argc, char* argv[])
bool timeoutOk = false; bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable); const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable);
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable); const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable); const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
const QString launchToken = config.getValue("App", "launch_token"); const QString launchToken = config.getValue("App", "launch_token");
const auto launchMainApp = [&](const QString& healthFile = QString()) { QString mainStartupError;
QString ticketPath; const auto launchMainApp = [&](const QString& healthFile = QString()) {
QString ticketError; mainStartupError.clear();
const QString launchVersion = config.getValue("App", "current_version"); if (!QFileInfo::exists(mainAppPath)) {
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken, mainStartupError = QCoreApplication::translate(
&ticketPath, &ticketError)) { "Updater",
qDebug() << "Cannot create launch ticket:" << ticketError; "Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
return false; .arg(mainAppPath);
} return false;
QStringList args{QString("--ticket-file=%1").arg(ticketPath)}; }
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); QString ticketPath;
const bool started = QProcess::startDetached(mainAppPath, args); QString ticketError;
if (!started) QFile::remove(ticketPath); const QString launchVersion = config.getValue("App", "current_version");
return started; if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
}; &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false;
}
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
const bool started = QProcess::startDetached(mainAppPath, args);
if (!started) {
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nHealth file: %3\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath, healthFile.isEmpty() ? QCoreApplication::translate("Updater", "<not used>") : healthFile);
}
return started;
};
const auto bootstrapPlanFile = [&]() { const auto bootstrapPlanFile = [&]() {
return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt"); return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt");
}; };
@@ -282,16 +307,19 @@ int main(int argc, char* argv[])
if (resumingFromBootstrap) { if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"), return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation.")); withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
logic.errorString()));
} else if (offlinePackagePath.isEmpty()) { } else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.getManifest(appId, channel, targetVersion, targetVersionId);
} }
if (!logic.verifyManifestSignature()) { if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"), return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"),
QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation.")); withDetails(QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"), return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."), withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
logic.errorString()),
"manifest_signature_invalid"); "manifest_signature_invalid");
} }
QStringList obsoletePaths; QStringList obsoletePaths;
@@ -309,9 +337,11 @@ int main(int argc, char* argv[])
if (!logic.saveManifestCache(manifestCacheDir)) { if (!logic.saveManifestCache(manifestCacheDir)) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"), return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache.")); withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"), return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."), withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
logic.errorString()),
"manifest_cache_failed"); "manifest_cache_failed");
} }
@@ -324,7 +354,8 @@ int main(int argc, char* argv[])
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
if (logic.getFileList().isEmpty()) if (logic.getFileList().isEmpty())
return fail(QCoreApplication::translate("Updater", "No Files to Update"), return fail(QCoreApplication::translate("Updater", "No Files to Update"),
QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."), withDetails(QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
logic.errorString()),
"empty_file_list"); "empty_file_list");
QStorageInfo storage(updateDir); QStorageInfo storage(updateDir);
@@ -353,7 +384,8 @@ int main(int argc, char* argv[])
if (!logic.downloadAllFiles(stagingDir, targetDir)) { if (!logic.downloadAllFiles(stagingDir, targetDir)) {
logic.reportDownloadResult(appId, channel, targetVersion, false); logic.reportDownloadResult(appId, channel, targetVersion, false);
return fail(QCoreApplication::translate("Updater", "Download Failed"), return fail(QCoreApplication::translate("Updater", "Download Failed"),
QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network and try again."), withDetails(QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network, update cache, or server release files and try again."),
logic.errorString()),
"download_failed"); "download_failed");
} }
logic.reportDownloadResult(appId, channel, targetVersion, true); logic.reportDownloadResult(appId, channel, targetVersion, true);
@@ -362,7 +394,8 @@ int main(int argc, char* argv[])
setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files...")); setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files..."));
if (!logic.validateLocalFiles(stagingDir, targetDir)) if (!logic.validateLocalFiles(stagingDir, targetDir))
return fail(QCoreApplication::translate("Updater", "File Verification Failed"), return fail(QCoreApplication::translate("Updater", "File Verification Failed"),
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."), withDetails(QCoreApplication::translate("Updater", "The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files."),
logic.errorString()),
"staging_verify_failed"); "staging_verify_failed");
QStringList changedPaths; QStringList changedPaths;
@@ -441,7 +474,8 @@ int main(int argc, char* argv[])
setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result...")); setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result..."));
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir)) if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"), return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"),
QCoreApplication::translate("Updater", "New version files failed verification after installation.")); withDetails(QCoreApplication::translate("Updater", "New version files failed verification after installation. The updater will roll back to the previous version."),
logic.errorString()));
for (const QString& path : transaction.obsoletePaths()) { for (const QString& path : transaction.obsoletePaths()) {
if (QFile::exists(QDir(targetDir).filePath(path))) if (QFile::exists(QDir(targetDir).filePath(path)))
return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"), return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
@@ -459,7 +493,9 @@ int main(int argc, char* argv[])
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation...")); setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation..."));
if (!launchMainApp(healthFile)) if (!launchMainApp(healthFile))
return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"), return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"),
QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)); mainStartupError.isEmpty()
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
: mainStartupError);
QElapsedTimer healthTimer; QElapsedTimer healthTimer;
healthTimer.start(); healthTimer.start();
Binary file not shown.
File diff suppressed because it is too large Load Diff