diff --git a/.gitignore b/.gitignore index a90c815..6c97c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,10 +26,11 @@ private/ pdf_requirements.txt # C/C++ generated artifacts -*.obj -*.o -*.pdb -*.ilk +*.obj +*.o +*.pdb +*.qm +*.ilk *.idb *.tlog *.lastbuildstate diff --git a/CMakeLists.txt b/CMakeLists.txt index 05a170f..57c5fbc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,21 +113,20 @@ add_subdirectory(MainApp) # 默认启动项目 set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) -# Copy config templates and static resources to output bin folder. -if(UNIX AND NOT APPLE AND EXISTS "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json") - set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json") -else() - set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") -endif() +# Copy local-only static resources to output bin folder. Final customer +# app_config.json is generated by the Go server when a release package is +# uploaded. Developers may create an untracked config/app_config.local.json for +# local debugging. set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config") set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem") +set(LOCAL_APP_CONFIG_FILE "${CONFIG_SOURCE_DIR}/app_config.local.json") set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") # app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移 file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config") -if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini") - configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) +if(EXISTS "${LOCAL_APP_CONFIG_FILE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini") + configure_file("${LOCAL_APP_CONFIG_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) endif() foreach(RUNTIME_RESOURCE local_state.json version_policy.dat) if(EXISTS "${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}") @@ -152,9 +151,6 @@ add_dependencies(MainApp update_client_translations) add_dependencies(Bootstrap update_client_translations) # Install rules for packaging -if(EXISTS "${APP_CONFIG_SOURCE_FILE}") - install(FILES "${APP_CONFIG_SOURCE_FILE}" DESTINATION bin/config RENAME app_config.json) -endif() if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}") install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config) install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin) diff --git a/Common/CMakeLists.txt b/Common/CMakeLists.txt index aaf3e1a..0b41331 100644 --- a/Common/CMakeLists.txt +++ b/Common/CMakeLists.txt @@ -1,23 +1,23 @@ project(Common LANGUAGES C CXX) set(SRC - HttpHelper.h - HttpHelper.cpp - FileHelper.h - FileHelper.cpp + HttpHelper.h + HttpHelper.cpp + FileHelper.h + FileHelper.cpp ConfigHelper.h ConfigHelper.cpp PolicyHelper.h PolicyHelper.cpp LocalStateHelper.h LocalStateHelper.cpp - TicketHelper.h - TicketHelper.cpp - IntegrityHelper.h - IntegrityHelper.cpp - DeviceIdentityHelper.h - DeviceIdentityHelper.cpp -) + TicketHelper.h + TicketHelper.cpp + UpdatePathPolicy.h + UpdatePathPolicy.cpp + IntegrityHelper.h + IntegrityHelper.cpp +) add_library(Common STATIC ${SRC}) # Common编译自身需要OpenSSL头文件 diff --git a/Common/ConfigHelper.cpp b/Common/ConfigHelper.cpp index e20c5f4..c4258c6 100644 --- a/Common/ConfigHelper.cpp +++ b/Common/ConfigHelper.cpp @@ -35,8 +35,8 @@ const QString kConfigKeyPrefix = QStringLiteral("--config-key-b64="); const QString kConfigValuePrefix = QStringLiteral("--config-value-b64="); const QString kFilePathPrefix = QStringLiteral("--file-path-b64="); const QString kFileDataPrefix = QStringLiteral("--file-data-b64="); -const QString kRegistryOrganization = QStringLiteral("Marsco"); -const QString kRegistryApplication = QStringLiteral("UpdateClientSDK"); +const QString kRegistryOrganization = QStringLiteral("SimCAE"); +const QString kRegistryApplication = QStringLiteral("HubUpdateClient"); const QString kRegistryInstallationsGroup = QStringLiteral("installations"); const QString kRegistryConfigGroup = QStringLiteral("config"); const QString kRegistryMetaGroup = QStringLiteral("_meta"); @@ -178,7 +178,8 @@ bool isRegistryManagedConfigKey(const QString& key) { // 服务端地址是编译期 qrc 配置,不进入注册表。 // 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。 - return key != kApiBaseUrlKey; + Q_UNUSED(key); + return true; } bool isPathInsideDirectory(const QString& path, const QString& directory) @@ -478,7 +479,6 @@ ConfigHelper::ConfigHelper() QCryptographicHash::Sha256).toHex()); migrateLegacyIniIfNeeded(); syncRegistryFromConfigFileIfChanged(); - removeRegistryValue(kApiBaseUrlKey); qDebug() << "Loading app config path:" << m_configPath; qDebug() << "File exists?" << QFile::exists(m_configPath); qDebug() << "Registry installation id:" << m_registryInstallId; @@ -508,7 +508,7 @@ QString ConfigHelper::dataRoot() const if (base.isEmpty()) base = QDir::homePath(); return QDir::cleanPath(QDir(base).filePath( - QStringLiteral("Marsco/UpdateClientSDK/installations/%1").arg(m_registryInstallId))); + QStringLiteral("SimCAE/HubUpdateClient/installations/%1").arg(m_registryInstallId))); } QString ConfigHelper::dataConfigDir() const @@ -788,16 +788,18 @@ QString ConfigHelper::readFileValue(const QString& key) const QString ConfigHelper::getValue(const QString& section, const QString& key) const { Q_UNUSED(section); - const QString embeddedValue = readEmbeddedValue(key); - if (!embeddedValue.isEmpty()) - return embeddedValue; if (!isRegistryManagedConfigKey(key)) return QString(); QString value; if (readRegistryValue(key, &value)) return value; - return readFileValue(key); + + value = readFileValue(key).trimmed(); + if (!value.isEmpty()) + return value; + + return readEmbeddedValue(key); } bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value) @@ -824,15 +826,15 @@ bool ConfigHelper::migrateLegacyIniIfNeeded() config.insert(key, value); }; - copyText("App", "app_id"); - copyText("App", "app_name", "Marsco Demo App"); + copyText("App", "app_id"); + copyText("App", "product_code", ini.value("App/app_id").toString()); + copyText("App", "app_name", "SimCAE"); copyText("App", "channel", "stable"); copyText("App", "current_version", "1.0.0"); - copyText("App", "client_protocol", "3"); - copyText("App", "launch_token"); - copyText("License", "license_key"); - copyText("Server", "api_base_url"); - copyText("Server", "client_token"); + copyText("App", "client_protocol", "3"); + copyText("Server", "client_token"); + copyText("App", "launch_token"); + copyText("Server", "api_base_url"); copyText("Update", "request_timeout_ms", "5000"); copyText("Update", "temp_folder", "update_temp"); copyText("Update", "device_id"); @@ -842,6 +844,9 @@ bool ConfigHelper::migrateLegacyIniIfNeeded() copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater")); copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap")); copyText("Runtime", "health_check_timeout_ms", "15000"); + copyText("Security", "require_manifest_signature", "false"); + copyText("Security", "verify_installed_on_start", "false"); + copyText("Platform", "abi"); #ifdef Q_OS_WIN config.insert("platform", "windows"); #elif defined(Q_OS_LINUX) @@ -849,7 +854,7 @@ bool ConfigHelper::migrateLegacyIniIfNeeded() #else config.insert("platform", "unknown"); #endif - config.insert("arch", "x64"); + config.insert("arch", "x86_64"); QDir().mkpath(QFileInfo(m_configPath).path()); QSaveFile output(m_configPath); diff --git a/Common/DeviceIdentityHelper.cpp b/Common/DeviceIdentityHelper.cpp deleted file mode 100644 index 40f88a5..0000000 --- a/Common/DeviceIdentityHelper.cpp +++ /dev/null @@ -1,323 +0,0 @@ -#include "DeviceIdentityHelper.h" -#include "ConfigHelper.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef HAVE_OPENSSL -#include -#include -#endif - -namespace { - -QString manifestPublicKeyPath(const QString &installDir) -{ - return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem")); -} - -} // namespace - -DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir) - : m_installDir(installDir) -{ -} - -QString DeviceIdentityHelper::deviceId() const -{ - return m_deviceId; -} - -QString DeviceIdentityHelper::errorString() const -{ - return m_error; -} - -bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64) -{ -#ifndef HAVE_OPENSSL - Q_UNUSED(payload); - Q_UNUSED(signatureBase64); - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "OpenSSL is unavailable, so device credential signature cannot be verified."); - return false; -#else - const QString keyPath = manifestPublicKeyPath(m_installDir); - QFile keyFile(keyPath); - if (!keyFile.open(QIODevice::ReadOnly)) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath); - return false; - } - - const QByteArray keyData = keyFile.readAll(); - BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); - EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; - if (bio) { - BIO_free(bio); - } - if (!publicKey) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath); - return false; - } - - EVP_MD_CTX *ctx = EVP_MD_CTX_new(); - const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); - const bool ok = ctx - && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1 - && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 - && EVP_DigestVerifyFinal( - ctx, - reinterpret_cast(signature.constData()), - signature.size()) == 1; - - if (ctx) { - EVP_MD_CTX_free(ctx); - } - EVP_PKEY_free(publicKey); - - if (!ok) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "Device credential signature is invalid. The local identity file may not match this server."); - } - return ok; -#endif -} - -bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel) -{ - // client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。 - // 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。 - QString credentialPath = ConfigHelper::instance().clientIdentityPath(); - if (!QFile::exists(credentialPath)) { - credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat")); - } - - QFile credentialFile(credentialPath); - if (!credentialFile.open(QIODevice::ReadOnly)) { - return false; - } - - QJsonParseError parseError; - const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError); - if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1") - .arg(credentialPath); - return false; - } - - const QJsonObject wrapper = wrapperDoc.object(); - const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8(); - const QString signature = wrapper.value(QStringLiteral("signature")).toString(); - if (identityText.isEmpty() || !verifySignature(identityText, signature)) { - return false; - } - - const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText); - const QJsonObject identity = identityDoc.object(); - const QDateTime expiry = QDateTime::fromString( - identity.value(QStringLiteral("valid_until")).toString(), - Qt::ISODate); - - const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId - && identity.value(QStringLiteral("channel")).toString() == expectedChannel - && !identity.value(QStringLiteral("license_id")).toString().isEmpty() - && !identity.value(QStringLiteral("installation_id")).toString().isEmpty() - && !identity.value(QStringLiteral("device_id")).toString().isEmpty(); - if (!identityMatches) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "Device credential does not match this application, channel, license, installation or device."); - return false; - } - - if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "License has expired. Please ask the administrator to issue a new License."); - return false; - } - - m_deviceId = identity.value(QStringLiteral("device_id")).toString(); - return true; -} - -bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel) -{ - m_error.clear(); - return loadAndVerify(appId, channel); -} - -bool DeviceIdentityHelper::ensureIssued( - const QString &apiBaseUrl, - const QString &clientToken, - const QString &appId, - const QString &channel, - const QString &licenseKey) -{ - // 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。 - // 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。 - m_error.clear(); - if (loadAndVerify(appId, channel)) { - ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId); - return true; - } - - const QString trimmedBaseUrl = apiBaseUrl.trimmed(); - if (appId.trimmed().isEmpty()) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json."); - return false; - } - if (channel.trimmed().isEmpty()) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json."); - return false; - } - if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000"); - return false; - } - if (clientToken.trimmed().isEmpty()) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "client_token is empty. Copy the client_token generated by the admin page into app_config.json."); - return false; - } - if (licenseKey.trimmed().isEmpty()) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "License is empty. Create or select a License in the admin page, then copy the generated client configuration."); - return false; - } - - ConfigHelper &config = ConfigHelper::instance(); - QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")); - if (installationId.isEmpty()) { - installationId = QUuid::createUuid().toString(QUuid::WithoutBraces); - if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2") - .arg(config.configPath(), config.lastError()); - return false; - } - } - - const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8(); - const QString machineHash = QString::fromLatin1( - QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex()); - const QJsonObject body{ - {QStringLiteral("app_id"), appId}, - {QStringLiteral("channel"), channel}, - {QStringLiteral("license_key"), licenseKey}, - {QStringLiteral("installation_id"), installationId}, - {QStringLiteral("machine_hash"), machineHash}, - }; - - QNetworkAccessManager manager; - QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))}; - request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); - request.setRawHeader("X-Client-Token", clientToken.toUtf8()); - - QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)); - QEventLoop loop; - QTimer timer; - timer.setSingleShot(true); - - bool timeoutOk = false; - int timeoutMs = ConfigHelper::instance() - .getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms")) - .toInt(&timeoutOk); - if (!timeoutOk || timeoutMs < 1000) { - timeoutMs = 5000; - } - - QObject::connect(&timer, &QTimer::timeout, [&]() { - if (reply && reply->isRunning()) { - reply->abort(); - } - }); - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - - timer.start(timeoutMs); - loop.exec(); - timer.stop(); - - const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QString networkError = reply->errorString(); - const QByteArray raw = reply->readAll(); - reply->deleteLater(); - if (status != 200) { - QJsonParseError responseError; - const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError); - 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", "") : serverMessage); - } - return false; - } - - const QJsonDocument responseDoc = QJsonDocument::fromJson(raw); - const QJsonObject response = responseDoc.object(); - if (response.value(QStringLiteral("identity_text")).toString().isEmpty() - || response.value(QStringLiteral("signature")).toString().isEmpty()) { - m_error = QCoreApplication::translate( - "DeviceIdentityHelper", - "Server returned an invalid device identity response."); - return false; - } - - const QJsonObject wrapper{ - {QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))}, - {QStringLiteral("signature"), response.value(QStringLiteral("signature"))}, - }; - const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); - const QString credentialPath = config.clientIdentityPath(); - - QString writeError; - if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2") - .arg(credentialPath, writeError); - return false; - } - if (!loadAndVerify(appId, channel)) { - return false; - } - if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) { - m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2") - .arg(config.configPath(), config.lastError()); - return false; - } - return true; -} diff --git a/Common/DeviceIdentityHelper.h b/Common/DeviceIdentityHelper.h deleted file mode 100644 index c37f1e4..0000000 --- a/Common/DeviceIdentityHelper.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include - -class DeviceIdentityHelper { -public: - explicit DeviceIdentityHelper(const QString &installDir); - - bool ensureIssued( - const QString &apiBaseUrl, - const QString &clientToken, - const QString &appId, - const QString &channel, - const QString &licenseKey); - bool verifyLocal(const QString &appId, const QString &channel); - - QString deviceId() const; - QString errorString() const; - -private: - bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel); - bool verifySignature(const QByteArray &payload, const QString &signatureBase64); - - QString m_installDir; - QString m_deviceId; - QString m_error; -}; diff --git a/Common/HttpHelper.cpp b/Common/HttpHelper.cpp index a58cfa2..ee8626b 100644 --- a/Common/HttpHelper.cpp +++ b/Common/HttpHelper.cpp @@ -3,68 +3,132 @@ #include "ConfigHelper.h" #include #include -#include -#include - -void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, - std::function callback) -{ - QNetworkAccessManager* manager = new QNetworkAccessManager(); - manager->setProxy(QNetworkProxy::NoProxy); - - QNetworkRequest req(url); - req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - // Add auth token header - QString token = ConfigHelper::instance().getValue("Server", "client_token"); - req.setRawHeader("X-Client-Token", token.toUtf8()); - QString identityPath = ConfigHelper::instance().clientIdentityPath(); - if (!QFile::exists(identityPath)) - identityPath = QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"); - QFile identity(identityPath); - if (identity.open(QIODevice::ReadOnly)) - req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); - - QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); - qDebug() << "=== POST Request ==="; - qDebug() << "Url:" << url; - qDebug() << "Body:" << data; - - QNetworkReply* reply = manager->post(req, data); - QEventLoop loop; - bool timeoutOk = false; - int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); - if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000; - QTimer timer; - timer.setSingleShot(true); - QObject::connect(&timer, &QTimer::timeout, [&]() { - if (reply && reply->isRunning()) { - qDebug() << "Request timeout, abort:" << url; - reply->abort(); - } - }); - - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - timer.start(timeoutMs); - loop.exec(); - timer.stop(); - - int retCode = 0; - QJsonObject retObj; - - retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const QByteArray respData = reply->readAll(); - if (!respData.isEmpty()) { - qDebug() << "Server raw response:" << respData; - retObj = QJsonDocument::fromJson(respData).object(); - } - if (reply->error() != QNetworkReply::NoError) - { - qDebug() << "Network error code:" << reply->error(); - qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); - } - - callback(retCode, retObj); - - reply->deleteLater(); - manager->deleteLater(); +#include +#include +#include +#include + +namespace { + +int requestTimeoutMs() +{ + bool timeoutOk = false; + int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); + if (!timeoutOk || timeoutMs < 1000) + timeoutMs = 5000; + return timeoutMs; +} + +void applyCommonHeaders(QNetworkRequest& req, const QString& bearerToken) +{ + const QString clientToken = ConfigHelper::instance().getValue(QStringLiteral("Server"), QStringLiteral("client_token")).trimmed(); + if (!clientToken.isEmpty()) + req.setRawHeader("X-Client-Token", clientToken.toUtf8()); + + const QString token = bearerToken.trimmed(); + if (!token.isEmpty()) + req.setRawHeader("Authorization", QByteArray("Bearer ") + token.toUtf8()); +} + +void readJsonReply(QNetworkReply* reply, int* retCode, QJsonObject* retObj) +{ + *retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray respData = reply->readAll(); + if (!respData.isEmpty()) { + qDebug() << "Server raw response:" << respData; + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(respData, &parseError); + if (parseError.error == QJsonParseError::NoError && document.isObject()) + *retObj = document.object(); + } + if (reply->error() != QNetworkReply::NoError) + { + qDebug() << "Network error code:" << reply->error(); + qDebug() << "HTTP status:" << *retCode << "detail:" << reply->errorString(); + } +} + +void waitForReply(const QString& url, QNetworkReply* reply) +{ + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, [&]() { + if (reply && reply->isRunning()) { + qDebug() << "Request timeout, abort:" << url; + reply->abort(); + } + }); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + timer.start(requestTimeoutMs()); + loop.exec(); + timer.stop(); +} + +} // namespace + +void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, + std::function callback) +{ + postRequest(url, jsonBody, QString(), callback); +} + +void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, + const QString& bearerToken, + std::function callback) +{ + QNetworkAccessManager* manager = new QNetworkAccessManager(); + manager->setProxy(QNetworkProxy::NoProxy); + + QNetworkRequest req{QUrl(url)}; + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + applyCommonHeaders(req, bearerToken); + + QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); + qDebug() << "=== POST Request ==="; + qDebug() << "Url:" << url; + qDebug() << "Body:" << data; + + QNetworkReply* reply = manager->post(req, data); + waitForReply(url, reply); + + int retCode = 0; + QJsonObject retObj; + readJsonReply(reply, &retCode, &retObj); + + callback(retCode, retObj); + + reply->deleteLater(); + manager->deleteLater(); +} + +void HttpHelper::getRequest(const QString& url, + std::function callback) +{ + getRequest(url, QString(), callback); +} + +void HttpHelper::getRequest(const QString& url, const QString& bearerToken, + std::function callback) +{ + QNetworkAccessManager* manager = new QNetworkAccessManager(); + manager->setProxy(QNetworkProxy::NoProxy); + + QNetworkRequest req{QUrl(url)}; + applyCommonHeaders(req, bearerToken); + + qDebug() << "=== GET Request ==="; + qDebug() << "Url:" << url; + + QNetworkReply* reply = manager->get(req); + waitForReply(url, reply); + + int retCode = 0; + QJsonObject retObj; + readJsonReply(reply, &retCode, &retObj); + + callback(retCode, retObj); + + reply->deleteLater(); + manager->deleteLater(); } diff --git a/Common/HttpHelper.h b/Common/HttpHelper.h index 52dcc29..9538ded 100644 --- a/Common/HttpHelper.h +++ b/Common/HttpHelper.h @@ -3,15 +3,23 @@ #include #include #include -#include -#include -#include -#include - -class HttpHelper -{ -public: +#include +#include +#include +#include +#include + +class HttpHelper +{ +public: // Create an independent manager for each call instead of keeping it as a member. - static void postRequest(const QString& url, const QJsonObject& jsonBody, - std::function callback); + static void postRequest(const QString& url, const QJsonObject& jsonBody, + std::function callback); + static void postRequest(const QString& url, const QJsonObject& jsonBody, + const QString& bearerToken, + std::function callback); + static void getRequest(const QString& url, + std::function callback); + static void getRequest(const QString& url, const QString& bearerToken, + std::function callback); }; diff --git a/Common/IntegrityHelper.cpp b/Common/IntegrityHelper.cpp index d29f960..66b01d8 100644 --- a/Common/IntegrityHelper.cpp +++ b/Common/IntegrityHelper.cpp @@ -1,52 +1,62 @@ -#include "IntegrityHelper.h" -#include "ConfigHelper.h" -#include -#include -#include -#include +#include "IntegrityHelper.h" +#include "ConfigHelper.h" +#include "UpdatePathPolicy.h" +#include +#include +#include +#include #include #include #include #include #include #include -#ifdef HAVE_OPENSSL -#include -#include -#endif - -IntegrityHelper::IntegrityHelper(const QString& installDir) +#ifdef HAVE_OPENSSL +#include +#include +#endif + +namespace { + +QString configValue(const QString& key, const QString& fallback = QString()) +{ + const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed(); + return value.isEmpty() ? fallback : value; +} + +bool configFlag(const QString& key) +{ + const QString value = configValue(key).toLower(); + return value == QStringLiteral("true") + || value == QStringLiteral("1") + || value == QStringLiteral("yes") + || value == QStringLiteral("on"); +} + +bool manifestFileRequired(const QJsonObject& item) +{ + if (!item.contains(QStringLiteral("required"))) + return true; + return item.value(QStringLiteral("required")).toBool(true); +} + +} // namespace + +IntegrityHelper::IntegrityHelper(const QString& installDir) : m_installDir(QDir::cleanPath(installDir)) {} QString IntegrityHelper::errorString() const { return m_error; } -bool IntegrityHelper::safeRelativePath(const QString& path) const -{ - const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); - return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." - && !clean.startsWith("../") && !clean.contains(":"); -} - +bool IntegrityHelper::safeRelativePath(const QString& path) const +{ + return UpdatePathPolicy::isSafeRelativePath(path); +} + bool IntegrityHelper::runtimeProtectedPath(const QString& path) const { - // 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。 - // 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。 - const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); - QSet protectedPaths{ - "bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", - "config/client_identity.dat", "config/version_policy.dat" - }; - const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); - if (!runtimePrefix.isEmpty()) { - const QStringList runtimeProtected{ - "bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", - "config/client_identity.dat", "config/version_policy.dat" - }; - for (const QString& protectedPath : runtimeProtected) - protectedPaths.insert(runtimePrefix + "/" + protectedPath); - } - return protectedPaths.contains(p); -} + return UpdatePathPolicy::isFullUpdateProtectedPath( + path, ConfigHelper::instance().runtimeRelativePath()); +} QString IntegrityHelper::sha256(const QString& filePath) const { @@ -127,16 +137,39 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString .arg(version, cachePath, wrapperError.errorString()); return false; } - const QJsonObject wrapper = wrapperDoc.object(); - const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); - const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); - if (manifestText.isEmpty() || signature.isEmpty()) { + const QJsonObject wrapper = wrapperDoc.object(); + QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8(); + if (manifestText.isEmpty()) + manifestText = wrapper.value("manifest_text").toString().toUtf8(); + const QString manifestSha256 = wrapper.value("manifestSha256").toString( + wrapper.value("manifest_sha256").toString()); + const QString signature = wrapper.value("signature").toString( + wrapper.value("manifest").toObject().value("signature").toString()); + const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty()); + if (manifestText.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; + if (!manifestSha256.isEmpty()) { + const QString actualSha = QString::fromLatin1( + QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()); + if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) { + m_error = QCoreApplication::translate("IntegrityHelper", + "Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3") + .arg(version, manifestSha256, actualSha); + return false; + } + } + if (signedManifest && !signature.isEmpty()) { + if (!verifySignature(manifestText, signature)) return false; + } else if (configFlag(QStringLiteral("require_manifest_signature"))) { + m_error = QCoreApplication::translate("IntegrityHelper", + "Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.") + .arg(version); + return false; + } QJsonParseError manifestError; const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); @@ -147,20 +180,23 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString return false; } const QJsonObject manifest = manifestDoc.object(); - if (manifest.value("app_id").toString() != appId + const QString manifestProduct = manifest.value("productCode").toString( + manifest.value("app_id").toString()); + if (manifestProduct != appId || manifest.value("channel").toString() != channel || manifest.value("version").toString() != version) { 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.") + "Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.") .arg(appId, channel, version, - manifest.value("app_id").toString(), + manifestProduct, manifest.value("channel").toString(), manifest.value("version").toString()); return false; } - QSet declaredExecutables; - for (const QJsonValue& value : manifest.value("files").toArray()) { + QSet declaredExecutables; + QSet optionalComponentDirs; + for (const QJsonValue& value : manifest.value("files").toArray()) { const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); if (!safeRelativePath(path)) { @@ -169,6 +205,14 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString .arg(version, path); return false; } + if (UpdatePathPolicy::isExecutableOrLibrary(path)) + declaredExecutables.insert(path.toCaseFolded()); + if (!manifestFileRequired(item)) { + const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path()); + if (!dir.isEmpty() && dir != QStringLiteral(".")) + optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded()); + continue; + } if (runtimeProtectedPath(path)) continue; const QString fullPath = QDir(m_installDir).filePath(path); if (!QFile::exists(fullPath)) { @@ -177,6 +221,17 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString .arg(version, path, fullPath); return false; } + const qint64 expectedSize = item.contains("sizeBytes") + ? item.value("sizeBytes").toVariant().toLongLong() + : item.value("size").toVariant().toLongLong(); + if ((item.contains("sizeBytes") || item.contains("size")) + && QFileInfo(fullPath).size() != expectedSize) { + m_error = QCoreApplication::translate("IntegrityHelper", + "Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes") + .arg(version, path, fullPath, + QString::number(expectedSize), QString::number(QFileInfo(fullPath).size())); + return false; + } const QString expected = item.value("sha256").toString(); const QString actual = sha256(fullPath); if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { @@ -186,9 +241,7 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "") : actual); return false; } - const QString suffix = QFileInfo(path).suffix().toCaseFolded(); - if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); - } + } QDir root(m_installDir); QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories); @@ -202,10 +255,18 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString const bool runtimeWorkDir = !runtimePrefix.isEmpty() && (folded.startsWith(runtimePrefix + "/update/") || folded.startsWith(runtimePrefix + "/update_temp/")); - if (folded.startsWith("update/") || folded.startsWith("update_temp/") - || runtimeWorkDir || runtimeProtectedPath(relative)) continue; - const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); - if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { + if (folded.startsWith("update/") || folded.startsWith("update_temp/") + || runtimeWorkDir || runtimeProtectedPath(relative)) continue; + bool optionalComponentFile = false; + for (const QString& prefix : optionalComponentDirs) { + if (folded.startsWith(prefix)) { + optionalComponentFile = true; + break; + } + } + if (optionalComponentFile) + continue; + if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) { 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); diff --git a/Common/UpdatePathPolicy.cpp b/Common/UpdatePathPolicy.cpp new file mode 100644 index 0000000..c7292fd --- /dev/null +++ b/Common/UpdatePathPolicy.cpp @@ -0,0 +1,127 @@ +#include "UpdatePathPolicy.h" + +#include +#include +#include +#include + +namespace { + +bool exactOrRuntimeMatch(const QString& folded, const QString& runtimePrefix, + const QSet& exactPaths) +{ + if (exactPaths.contains(folded)) + return true; + if (runtimePrefix.isEmpty() || !folded.startsWith(runtimePrefix + QStringLiteral("/"))) + return false; + return exactPaths.contains(folded.mid(runtimePrefix.size() + 1)); +} + +bool prefixOrRuntimePrefixMatch(const QString& folded, const QString& runtimePrefix, + const QString& prefix) +{ + if (folded.startsWith(prefix)) + return true; + if (runtimePrefix.isEmpty()) + return false; + return folded.startsWith(runtimePrefix + QStringLiteral("/") + prefix); +} + +} // namespace + +namespace UpdatePathPolicy { + +QString normalizeRelativePath(const QString& path) +{ + QString normalized = QDir::cleanPath(QDir::fromNativeSeparators(path.trimmed())); + if (normalized == QStringLiteral(".")) + return QString(); + while (normalized.startsWith(QStringLiteral("./"))) + normalized = normalized.mid(2); + return normalized; +} + +bool isSafeRelativePath(const QString& path) +{ + const QString clean = normalizeRelativePath(path); + return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != QStringLiteral("..") + && !clean.startsWith(QStringLiteral("../")) && !clean.contains(QLatin1Char(':')); +} + +bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath) +{ + const QString folded = normalizeRelativePath(path).toCaseFolded(); + const QString runtimePrefix = normalizeRelativePath(runtimeRelativePath).toCaseFolded(); + const QSet exactPaths{ + QStringLiteral("bootstrap"), + QStringLiteral("bootstrap.exe"), + QStringLiteral("launcher"), + QStringLiteral("launcher.exe"), + QStringLiteral("updater"), + QStringLiteral("updater.exe"), + QStringLiteral("client.ini"), + QStringLiteral("config/app_config.json"), + QStringLiteral("config/local_state.json"), + QStringLiteral("config/client_identity.dat"), + QStringLiteral("config/version_policy.dat") + }; + if (exactOrRuntimeMatch(folded, runtimePrefix, exactPaths)) + return true; + return prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update/")) + || prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update_temp/")); +} + +bool isIFWInstallerManagedPath(const QString& path) +{ + const QString folded = normalizeRelativePath(path).toCaseFolded(); + if (folded.isEmpty()) + return false; + + const bool rootFile = !folded.contains(QLatin1Char('/')); + if (rootFile && (folded == QStringLiteral("maintenancetool") + || folded == QStringLiteral("maintenancetool.exe") + || folded.startsWith(QStringLiteral("maintenancetool.")))) { + return true; + } + + const QSet exactPaths{ + QStringLiteral("components.xml"), + QStringLiteral("components.xml.new"), + QStringLiteral("components.xml.old"), + QStringLiteral("installation.xml"), + QStringLiteral("installation.dat"), + QStringLiteral("installer.dat"), + QStringLiteral("installer.ini"), + QStringLiteral("network.xml"), + QStringLiteral("repositories.xml"), + QStringLiteral("repositories.cfg"), + QStringLiteral("repository.xml") + }; + if (exactPaths.contains(folded)) + return true; + + const QStringList prefixes{ + QStringLiteral("installerresources/"), + QStringLiteral("installationinformation/"), + QStringLiteral("licenses/") + }; + for (const QString& prefix : prefixes) { + if (folded.startsWith(prefix)) + return true; + } + return false; +} + +bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath) +{ + return isUpdaterRuntimeProtectedPath(path, runtimeRelativePath) + || isIFWInstallerManagedPath(path); +} + +bool isExecutableOrLibrary(const QString& path) +{ + const QString suffix = QFileInfo(path).suffix().toCaseFolded(); + return suffix == QStringLiteral("exe") || suffix == QStringLiteral("dll"); +} + +} // namespace UpdatePathPolicy diff --git a/Common/UpdatePathPolicy.h b/Common/UpdatePathPolicy.h new file mode 100644 index 0000000..6920e72 --- /dev/null +++ b/Common/UpdatePathPolicy.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace UpdatePathPolicy { + +QString normalizeRelativePath(const QString& path); +bool isSafeRelativePath(const QString& path); +bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath); +bool isIFWInstallerManagedPath(const QString& path); +bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath); +bool isExecutableOrLibrary(const QString& path); + +} diff --git a/Docs/00-先读我-客户端文档入口.txt b/Docs/00-先读我-客户端文档入口.txt index 4664bd4..066b467 100644 --- a/Docs/00-先读我-客户端文档入口.txt +++ b/Docs/00-先读我-客户端文档入口.txt @@ -1,81 +1,20 @@ -客户端文档入口 -============== +SimCAE Hub 客户端文档入口 +======================== -你第一次打开 update-client/Docs 时,先看这一份。这里告诉你每份文档是干什么的,以及不同角色应该从哪里开始。 +本目录记录 SimCAE Hub 的 Qt/C++ 客户端更新链路。当前方向是保留 Launcher / Updater / Bootstrap 的桌面客户端机制,适配 SimCAE Hub 当前 Go API,不用 Go 或 Web 技术重写客户端。 -文档阅读顺序 -============ +建议先按这个顺序阅读: 1. 01-客户端接入打包部署指南.md - 适合 SDK 接入方、测试人员和交付人员。按“生成 SDK -> 放进业务软件 -> 生成配置 -> 联调 -> 打最终包”的顺序写。 + 说明客户端运行链路、配置字段、打包方式和人工验证方法。 2. 02-编译环境和第三方依赖说明.md - 适合需要编译 Launcher、Updater、Bootstrap 的人。说明 Windows/Linux 下 Qt、OpenSSL、thirdparty/ 和 CMake 怎么准备。 + 说明 Windows 和 Linux 下编译 Qt/C++ 客户端需要的工具、Qt、OpenSSL 和 CMake 命令。 -3. ../i18n/ReadMe.txt - 适合维护界面文案的人。说明新增 tr() 后怎么更新 .ts、生成 .qm,并把翻译文件打进 qrc。 +3. ../config/server_config.json + 编译进客户端资源的服务端地址配置,当前测试服务器是 http://192.168.1.158:18000。 -常用任务入口 -============ +4. ../scripts/ReadMe.txt + SDK 打包脚本和客户安装包打包脚本的简短说明。 -如果你只是拿到 SDK 接入业务软件: - -```text -读 01-客户端接入打包部署指南.md 的“三、你:把 SDK 放进业务软件目录”和“四、你:生成并填写 app_config.json”。 -``` - -如果你要重新打 Windows SDK 包: - -```powershell -cd update-client -.\scripts\package-sdk.ps1 -SourceDir .\out\bin\Release -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0 -``` - -如果你要重新打 Linux SDK 包: - -```bash -cd update-client -cmake --preset linux-x64-release -cmake --build --preset linux-x64-release -bash ./scripts/package-sdk.sh --source-dir ./out/linux/bin --output-dir ./dist/UpdateClientSDK-linux --archive ./dist/UpdateClientSDK-linux.tar.gz --sdk-version 0.1.0 -``` - -客户端配置速记 -============== - -config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。后续运行配置优先从 QSettings 读取。 -config/server_config.json 是编译期服务端地址配置源文件。它通过 config/server_config.qrc 编进 Launcher / Updater / MainApp,不写入 app_config.json,也不写入注册表。修改 api_base_url 后必须重新编译客户端程序才会生效。 -如果 config/app_config.json 内容被修改,下一次启动时会按解析后的 JSON 内容 SHA256 判断变化并重新导入注册表。 -非空 app_config.json 成功导入注册表后会自动清空为 {},文件保留不删除,方便下次直接粘贴管理后台生成的新配置。 -Windows 注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。 -Linux 配置位置由 Qt QSettings 决定,通常在当前用户 home 目录的 .config/Marsco/UpdateClientSDK.conf 一类路径下。 -Windows 运行态数据目录类似:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\。 -如果检测到 app_config.json 发生变化,SDK 会删除当前用户数据目录里的 client_identity.dat、version_policy.dat 和 local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。 -正常启动成功后不要删除这些状态文件,它们用于本地身份、离线策略和安全状态。 -首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。 - -接入新软件时通常需要修改: - -1. app_id、app_name、channel、current_version。 -2. client_token、license_key、launch_token。 -3. config/server_config.json 里的 api_base_url。 -4. main_executable:团队业务主程序文件名。 -5. launcher_executable、updater_executable、bootstrap_executable。 -6. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。 - -Windows 完整格式参考 update-client/config/app_config.example.json;Linux 完整格式参考 update-client/config/app_config.linux.example.json。 -运行时生成的 client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。app_config.json 可以作为部署模板,但不要把某台机器运行后产生的临时状态混进去。 - -Windows 发布打包: - -1. 使用 Release 配置编译全部客户端程序。 -2. 先完成当前版本在线校验。签名 Manifest 缓存现在默认保存在当前用户数据目录: - Windows:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache - Linux:$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache,未设置 XDG_DATA_HOME 时通常是 ~/.local/share。 - 打包脚本会优先从用户数据目录读取,也兼容旧版 Release 目录里的 update/manifest_cache。 -3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名;确认客户端程序已用正确的 config/server_config.json 编译。 -4. 在 PowerShell 执行: - powershell -ExecutionPolicy Bypass -File .\scripts\package-client.ps1 -ConfigFile .\config\app_config.json -5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。 - -脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。 +客户端能力、接口链路、配置字段和接入限制统一看 01 文档。当前不包含邮箱、支付、告警、灰度发布等页面上没有的业务模块;崩溃报告作为旧系统兼容后端接口保留,具体看项目根目录的 使用教学.md。 diff --git a/Docs/01-客户端接入打包部署指南.md b/Docs/01-客户端接入打包部署指南.md index 0b8eba6..81e67da 100644 --- a/Docs/01-客户端接入打包部署指南.md +++ b/Docs/01-客户端接入打包部署指南.md @@ -1,432 +1,274 @@ -# UpdateClientSDK 客户端接入、打包和部署指南 +# SimCAE Hub 客户端接入、打包和部署指南 -本文按“维护者打包 SDK -> 你接入业务软件 -> 联调测试 -> 生成最终客户端包”的顺序说明。你拿到这份文档后,按章节一步一步做即可。 +本文说明 `update-client` 的当前实现。它保留 Launcher / Updater / Bootstrap 的桌面客户端机制,服务端协议使用 SimCAE Hub 当前 Go API。 -## 先看这里:你要做哪件事 +## 1. 适用范围 -| 你的目标 | 直接看哪一节 | +当前客户端只覆盖项目已有页面和接口对应的能力: + +1. 产品版本、软件发布、发布包和 Manifest。 +2. 客户授权、在线命名用户席位和门户受控下载。 +3. 在线检查更新、Manifest 拉取、受控下载、SHA-256 校验。 +4. Manifest 签名验签、临时文件、断点重试、安装前后完整性校验。 + +邮箱、支付、灰度、告警等页面上没有的能力不属于当前范围。崩溃报告是 SimCAE Hub 保留的旧系统兼容后端接口,不属于 Launcher / Updater / Bootstrap 的更新链路;接入方需要崩溃上报时,按 `使用教学.md` 里的崩溃报告接口说明调用。 + +## 2. 客户端程序组成 + +| 程序 | 作用 | | --- | --- | -| 重新生成给别人用的 SDK 包 | 二、维护者:生成 SDK 包 | -| 把 SDK 放到 SimCAE 或其他业务软件目录 | 三、你:把 SDK 放进业务软件目录 | -| 从后台生成 `app_config.json` 和 qrc 服务端配置 | 四、你:生成并填写客户端配置 | -| 给业务主程序接入启动保护代码 | 五、你:业务主程序接入要求 | -| 验证升级、回滚、健康检查 | 六、你:联调测试 | -| 生成最终交付给用户的客户端包 | 七、维护者:生成最终客户端包 | +| `Launcher` | 客户日常启动入口,负责导入配置、使用 `client_token` 检查更新、启动 Updater 或主程序 | +| `Updater` | 负责拉取 Manifest、下载发布包、校验文件、准备安装事务 | +| `Bootstrap` | 负责在需要替换运行中文件时接管安装,并把结果交回 Updater | +| `MainApp` | 示例主程序,用来验证 launch ticket 和安装后完整性校验 | +| `Common` | 配置、HTTP、票据、完整性校验等公共代码 | -## 一、这个 SDK 是什么 +## 3. 当前在线更新链路 -UpdateClientSDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力做成一组独立程序,让业务软件通过这些程序完成检查更新、下载、安装、回滚和启动保护。 +1. `Launcher` 启动后读取服务端生成的 `config/app_config.json`,并把静态配置导入当前用户的运行配置。 +2. 如果配置里没有 `api_base_url`,客户端会回退到编译进 EXE 资源中的 `server_config.json`。 +3. `Launcher` 确保存在 `device_id`,并检查 `client_token` 是否存在。 +4. `Launcher` 调用 `GET /api/v1/client/update/authorized-check`,请求头带 `X-Client-Token`。 +5. 如果服务端返回可用发布,`Launcher` 启动 `Updater`,并传入产品编码、渠道、目标版本和发布 ID。 +6. `Updater` 调用 `GET /api/v1/client/update/manifest`,请求头继续带 `X-Client-Token`。 +7. `Updater` 先校验服务端返回的 `manifestSha256`,再按配置决定是否强制要求 RSA-SHA256 签名。 +8. `Updater` 从 Manifest 中读取每个文件的 `downloadUrl`、`sizeBytes` 和 `sha256`。 +9. 下载请求统一带 `X-Client-Token`。 +10. 下载使用 `.part` 临时文件保存进度,请求失败后按网络重试策略处理。 +11. 文件下载完成后,客户端按 Manifest 校验文件大小和 SHA-256。 +12. 安装前校验 staging 目录,安装完成后保存 Manifest 缓存,并可在主程序启动时再次校验已安装文件。 -SDK 核心程序: +## 4. 关键配置字段 -- `Launcher.exe` / `Launcher`:用户入口。检查版本、验证授权和策略,决定直接启动业务主程序或进入升级流程。 -- `Updater.exe` / `Updater`:下载、校验、备份、安装、健康确认、提交或回滚。 -- `Bootstrap.exe` / `Bootstrap`:处理运行中可能被占用的 EXE/DLL 或 Linux 可执行文件替换。 -- `config/app_config.json`:部署配置源文件。启动时会同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。 -- `config/server_config.json`:编译期服务端地址配置源文件,通过 `config/server_config.qrc` 编进 Launcher / Updater / MainApp,不写入 `app_config.json` 或注册表。 -- `config/manifest_public_key.pem`:Manifest 签名公钥,用来验证服务端发布包没有被篡改。 +正式客户安装包里的 `config/app_config.json` 由服务端在上传发布包 ZIP 时自动生成。常用字段如下: -## 二、维护者:生成 SDK 包 +| 字段 | 说明 | +| --- | --- | +| `product_code` | SimCAE Hub 后台产品目录中的产品编码,例如 `stage2-dap` | +| `app_id` | 本地应用标识,默认和产品编码一致 | +| `channel` | 发布渠道,例如 `stable` | +| `current_version` | 当前本地安装版本,例如 `1.0.0` | +| `api_base_url` | 后端 API 地址,例如 `http://192.168.1.158:18000` | +| `client_token` | Launcher/Updater 调更新接口使用的部署级令牌,不绑定某一个客户 | +| `install_root` | 相对 Launcher/Updater 所在运行目录解析的更新根目录,决定 Updater、Bootstrap 和启动校验作用在哪棵目录 | +| `main_executable` | 相对运行目录解析的业务入口程序,通常是 `MainApp.exe` 或真实软件入口 | +| `launcher_executable` | 相对运行目录解析的 Launcher 文件名,主要用于提示和保持启动链路配置一致 | +| `updater_executable` | 相对运行目录解析的 Updater 文件名,Launcher 检查到更新后会启动它 | +| `bootstrap_executable` | 相对运行目录解析的 Bootstrap 文件名,Updater 需要替换文件时会启动它 | +| `platform` | 操作系统,例如 `windows` 或 `linux` | +| `arch` | 架构,例如 `x86_64` | +| `abi` | ABI,例如 `msvc`;没有时可留空 | +| `launch_token` | Launcher 和 MainApp 之间生成一次性启动票据的本地密钥 | +| `require_manifest_signature` | 是否强制要求 Manifest 必须带签名 | +| `verify_installed_on_start` | 主程序启动时是否按 Manifest 缓存校验已安装文件 | -这一节是 SDK 维护者操作。接入方通常只需要拿到 `UpdateClientSDK.zip`。 +`config/server_config.json` 会编译进客户端资源,作为 `api_base_url` 缺失时的兜底地址,当前测试服务器地址为: -打包前确认: +`http://192.168.1.158:18000` -1. 已在 Windows 上用 Release 配置编译完成,输出目录里有 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe`。 -2. `config/manifest_public_key.pem` 和服务端使用的私钥是一对。 -3. `update-client/Docs` 里的 Markdown 文档会随 SDK 一起打包,是默认接入说明来源。 -4. Word 接入说明是可选增强。如果本地有文件名包含 `SDK` 的 `.docx`,打包脚本会额外复制到 SDK 根目录;如果没有,也不会影响 SDK 打包。 -5. 如果业务软件本身已经带 Qt DLL,通常不要把 SDK 的 Qt 运行库打进去,避免 Qt 版本混用。 +如果换服务器,可以改完该文件后重新编译客户端;正式客户包通常由服务端写入 `api_base_url`,不需要把 `server_config.json` 暴露给客户。 -在 Windows PowerShell 中执行: +## 5. 编译 + +Windows Release 编译: ```powershell -cd C:\Users\admin\Desktop\update-client +cd update-client +cmake --preset x64-release +cmake --build --preset x64-release +``` +Linux Release 编译: + +```bash +cd update-client +cmake --preset linux-x64-release +cmake --build --preset linux-x64-release +``` + +## 6. 打包 SDK + +Windows 示例: + +```powershell +cd update-client .\scripts\package-sdk.ps1 ` -SourceDir .\out\bin\Release ` - -OutputDir .\dist\UpdateClientSDK ` - -ZipFile .\dist\UpdateClientSDK.zip ` + -OutputDir .\dist\SimCAEHubUpdateClientSDK ` + -ZipFile .\dist\SimCAEHubUpdateClientSDK.zip ` -SdkVersion 0.1.0 ``` -生成结果: +执行成功后会生成: -```text -dist/ - UpdateClientSDK/ - sdk_manifest.json - Docs/ - 00-先读我-客户端文档入口.txt - 01-客户端接入打包部署指南.md - 02-编译环境和第三方依赖说明.md - bin/ - Launcher.exe - Updater.exe - Bootstrap.exe - ... - config/ - app_config.json - manifest_public_key.pem - scripts/ - install-sdk.ps1 - package-client.ps1 - package-sdk.ps1 - SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现 - UpdateClientSDK.zip +1. 展开目录:`update-client\dist\SimCAEHubUpdateClientSDK` +2. 对外提供的 SDK 压缩包:`update-client\dist\SimCAEHubUpdateClientSDK.zip` + +默认不会打包 Qt DLL 和 Qt 插件目录,适合接入方已经有 Qt 运行环境,或者希望自己控制依赖部署的情况。 + +如果希望 SDK 包里带上 Qt runtime: + +```powershell +cd update-client +.\scripts\package-sdk.ps1 ` + -SourceDir .\out\bin\Release ` + -OutputDir .\dist\SimCAEHubUpdateClientSDK-with-qt ` + -ZipFile .\dist\SimCAEHubUpdateClientSDK-with-qt.zip ` + -SdkVersion 0.1.0 ` + -IncludeQtRuntime ``` -把 `dist/UpdateClientSDK.zip` 发给接入方即可。 +执行成功后会生成: -可选参数: +1. 展开目录:`update-client\dist\SimCAEHubUpdateClientSDK-with-qt` +2. 对外提供的 SDK 压缩包:`update-client\dist\SimCAEHubUpdateClientSDK-with-qt.zip` -- `-IncludeDemoMainApp`:把仓库里的 Demo 主程序 `MainApp.exe` 也打进 SDK,方便演示。 -- `-IncludeQtRuntime`:把 Qt 运行库也打进 SDK。只有业务软件本身不带 Qt 时才建议使用。 +其中 `-OutputDir` 是脚本整理 SDK 的展开目录,`-ZipFile` 是最终要交给接入方的 SDK 压缩包。接入方没有单独准备 Qt 运行库时,优先使用带 Qt runtime 的压缩包。 -Linux SDK 打包方式: +Linux 示例: ```bash -cd /home/laluo/project/update-client - -cmake --preset linux-x64-release -cmake --build --preset linux-x64-release - -bash ./scripts/package-sdk.sh \ +cd update-client +./scripts/package-sdk.sh \ --source-dir ./out/linux/bin \ - --output-dir ./dist/UpdateClientSDK-linux \ - --archive ./dist/UpdateClientSDK-linux.tar.gz \ + --output-dir ./dist/SimCAEHubUpdateClientSDK-linux \ + --archive ./dist/SimCAEHubUpdateClientSDK-linux.tar.gz \ --sdk-version 0.1.0 ``` -Linux SDK 包里核心程序名不带 `.exe`: +Linux 如需带上 Qt runtime,追加 `--include-qt-runtime`。 + +SDK 包不会包含最终 `app_config.json`、`server_config.json`、`server_config.qrc` 或 `manifest_public_key.pem`。这些最终配置在完整客户软件包上传到 SimCAE Hub 后由服务端生成。 + +## 7. 客户安装包配置 + +接入方应把以下文件放到客户软件目录的根目录或 `bin/` 目录: + +1. `Launcher` +2. `Updater` +3. `Bootstrap` +4. `MainApp` 或真实业务主程序 +5. `config/` 目录,可以先为空 + +### 7.1 标准目录结构和路径口径 + +更新系统不要求必须放在客户软件根目录。它可以放在 `SimCAE/` 根目录,也可以放在 `SimCAE/bin/` 目录。关键是让客户端配置里的 `install_root` 和服务端 Manifest 文件路径使用同一套口径。 + +先区分三个目录概念: + +| 概念 | 说明 | +| --- | --- | +| 运行目录 | Launcher、Updater、Bootstrap 所在目录,由客户端自动识别 | +| `install_root` | 相对运行目录解析的更新根目录,Updater 下载、校验、备份、回滚和 Bootstrap 替换文件都以它为范围 | +| Manifest `files[].path` | 服务端生成的安装相对路径,客户端会把它拼到 `install_root` 下面 | + +目录结构一:更新系统放在软件根目录。 ```text -dist/ - UpdateClientSDK-linux/ - sdk_manifest.json - Docs/ - 00-先读我-客户端文档入口.txt - 01-客户端接入打包部署指南.md - 02-编译环境和第三方依赖说明.md - bin/ - Launcher - Updater - Bootstrap - Common/ - ConfigHelper.h - ConfigHelper.cpp - TicketHelper.h - TicketHelper.cpp - config/ - app_config.json - manifest_public_key.pem - scripts/ - package-sdk.sh - package-client.sh - SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现 - UpdateClientSDK-linux.tar.gz -``` - -## 三、你:把 SDK 放进业务软件目录 - -假设业务软件目录是: - -```text -D:\SimCAE\ - bin\ - SimCAE.exe - Qt5Core.dll +SimCAE/ + Launcher.exe + Updater.exe + Bootstrap.exe + MainApp.exe + config/ + app_config.json + App/ ... - Licenses\ - installerResources\ ``` -推荐把 SDK 放到 `bin` 目录,和 `SimCAE.exe` 同级;后台发布新版本时仍选择整个 `D:\SimCAE\` 作为发布根目录。 +对应配置: -先解压 SDK: - -```powershell -Expand-Archive D:\交付\UpdateClientSDK.zip -DestinationPath D:\SimCAE_SDK -Force +```json +{ + "install_root": ".", + "main_executable": "MainApp.exe", + "launcher_executable": "Launcher.exe", + "updater_executable": "Updater.exe", + "bootstrap_executable": "Bootstrap.exe" +} ``` -再安装到业务软件的 `bin` 目录: - -```powershell -cd D:\SimCAE\bin - -D:\SimCAE_SDK\scripts\install-sdk.ps1 ` - -SdkRoot D:\SimCAE_SDK ` - -ReleaseDir . -``` - -安装后目录应类似: +目录结构二:更新系统和启动入口放在 `bin/`。 ```text -D:\SimCAE\ - bin\ +SimCAE/ + bin/ Launcher.exe Updater.exe Bootstrap.exe - SimCAE.exe - config\ + MainApp.exe + config/ app_config.json - manifest_public_key.pem - Qt5Core.dll + App/ ... - Licenses\ - installerResources\ ``` -如果你需要重新覆盖 `config/app_config.json`,执行安装脚本时加 `-OverwriteConfig`: - -```powershell -D:\SimCAE_SDK\scripts\install-sdk.ps1 ` - -SdkRoot D:\SimCAE_SDK ` - -ReleaseDir D:\SimCAE\bin ` - -OverwriteConfig -``` - -注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应复用 SimCAE 的 `Qt5*.dll`、`platforms/`、`imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则可能出现“无法定位程序输入点”一类错误。 - -## 四、你:生成并填写客户端配置 - -最推荐的方式是在服务端管理后台生成客户端配置: - -1. 浏览器打开服务端管理后台,例如 `http://服务器IP:8000/`。 -2. 登录后台。 -3. 创建或选择应用,例如 `app_id=simcae`。 -4. 创建 License。 -5. 在“客户端配置生成”区域选择应用、渠道、License 和主程序名。 -6. 点击生成配置。 -7. 复制“客户端 app_config.json”,覆盖 `D:\SimCAE\bin\config\app_config.json`。 -8. 复制“qrc 服务端配置 server_config.json”,覆盖 `update-client\config\server_config.json`,然后重新编译 Launcher / Updater / Bootstrap。这个文件会被 `config/server_config.qrc` 编进程序,不会放进用户机器的 `app_config.json` 或注册表。 - -配置同步规则: - -- `app_config.json` 是部署配置源文件,适合交付、复制、人工修改。 -- `api_base_url` 不再属于 `app_config.json` 字段。它只存在于 `config/server_config.json`,并通过 qrc 编进程序。 -- 交付给你的 SDK 运行包不会包含 `server_config.json` 和 `server_config.qrc`。它们是编译材料,不是运行配置;修改 SDK 包里的文件不会改变已经编译好的 `Launcher.exe`。 -- Launcher / Updater / MainApp 启动时会计算 `app_config.json` 解析后的 JSON 内容 SHA256;如果 JSON 内容和上次导入时不同,就把文件里的配置重新写入当前 Windows 用户的注册表。 -- 后续运行时优先从注册表读取配置,不再每次直接读 JSON。 -- 运行过程中产生的动态值,例如首次输入的 `license_key`、服务端返回的 `device_id`、升级后的 `current_version`,会写入注册表。 -- 为减少明文暴露,SDK 成功把非空 `app_config.json` 导入注册表后,会把 `app_config.json` 内容自动清空为 `{}`,但不会删除这个文件。以后你从管理后台复制新的客户端配置时,直接覆盖这个文件即可。 -- SDK 会同步更新注册表里的 `source_sha256`,所以 `{}` 不会在下一次启动时反向覆盖注册表配置。 -- 如果你手动修改了 `app_config.json`,下一次启动会重新导入并覆盖注册表里的同名字段。 -- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除当前用户数据目录里的 `client_identity.dat`、`version_policy.dat` 和 `local_state.json`,避免继续使用旧 License、旧设备身份、旧版本策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。 -- 正常启动成功后,不要删除 `client_identity.dat`、`version_policy.dat`、`local_state.json`。它们分别用于本地设备身份、离线策略和防回滚/防时间倒退,删除后会影响离线启动或导致重新授权。 -- 注册表按安装目录隔离;同一台电脑上多个安装目录不会互相覆盖配置。 -- 注册表位置为 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`。 - -关键字段说明: - -- `app_id`:服务端应用 ID,要和管理后台里的应用一致。 -- `app_name`:应用显示名称。 -- `channel`:发布渠道,例如 `stable`、`beta`、`dev`。 -- `current_version`:客户端当前初始版本,必须和后台已发布的版本一致。 -- `client_protocol`:客户端协议号,当前建议为 `3`。 -- `launch_token`:本机启动票据 HMAC 密钥。服务端生成配置时会填默认值;正式部署建议按项目统一修改。 -- `license_key`:管理后台创建 License 后生成的授权码。为空时首次启动 `Launcher.exe` 会弹窗让用户输入并保存到注册表;如果授权错误或过期,也会提示重新输入。 -- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,必须和服务端一致。 -- `device_id`:设备 ID。一般可以留空,首次启动时 SDK 会向服务端登记并写入注册表。 -- `install_root`:被更新的安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。 -- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径。SDK 放在 `bin` 且主程序也在 `bin` 时填 `SimCAE.exe`。 -- `launcher_executable`、`updater_executable`、`bootstrap_executable`:通常不用改。 -- `platform`、`arch`:当前为 `windows`、`x64`。 - -典型配置: +如果希望整个 `SimCAE/` 都属于更新范围,对应配置: ```json { - "app_id": "simcae", - "app_name": "SimCAE", - "channel": "stable", - "current_version": "1.0.0", - "client_protocol": "3", - "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", - "license_key": "MARSCO-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "client_token": "SimCAEClientToken2026", - "request_timeout_ms": "5000", - "temp_folder": "update_temp", - "device_id": "", "install_root": "..", - "main_executable": "SimCAE.exe", + "main_executable": "MainApp.exe", "launcher_executable": "Launcher.exe", "updater_executable": "Updater.exe", - "bootstrap_executable": "Bootstrap.exe", - "health_check_timeout_ms": "15000", - "platform": "windows", - "arch": "x64" + "bootstrap_executable": "Bootstrap.exe" } ``` -对应的 `config/server_config.json` 示例: +这表示 Launcher 从 `bin/` 启动 `MainApp.exe`,Updater 和 Bootstrap 更新的是 `bin/` 的上一级,也就是整个 `SimCAE/`。 + +服务端发布包路径要和客户端 `install_root` 对应: + +| 客户端配置 | 服务端 Manifest 路径口径 | +| --- | --- | +| 更新系统在根目录,`install_root` 是 `.` | `MainApp.exe`、`App/xxx.dll` 相对 `SimCAE/` | +| 更新系统在 `bin/`,`install_root` 是 `..` | `bin/MainApp.exe`、`App/xxx.dll` 相对 `SimCAE/` | + +当前管理后台“发布包”上传接口会使用上传文件名作为 `artifactName`,后端按安全文件名校验。当前稳定支持的是发布一个完整安装包或压缩包文件,或者把文件放在 `install_root` 根层级;还不是“自动解析压缩包并生成 App/bin 多文件 Manifest”的完整安装器。后续如果要让在线 Updater 直接把多个文件铺到 `App/`、`bin/` 等子目录,需要在现有发布包页面和 Go 后端上继续增强安全相对路径或 Manifest 文件清单生成能力。 + +如果后续要支持“更新系统在 `bin/`,但只校验和更新 `App/`”这类更窄的安装根目录,需要在管理后台和 Go 后端增加对应配置项,让服务端生成 `../App` 这类定制 `install_root`。当前服务端自动生成配置时只使用标准的 `.` 或 `..`。 + +如果开启 `verify_installed_on_start`,客户端会扫描 `install_root` 下的 EXE 和 DLL。整包 Manifest 中 `required=true` 的核心文件必须存在且 SHA-256 匹配;`required=false` 的可选组件文件可以由 MaintenanceTool 管理,缺失时不会阻止启动。可选组件建议放在独立目录中,例如 `plugins/dap/`,不要和核心程序 DLL 混放。 + +上传完整客户软件 ZIP 时,服务端会根据发布包记录自动写入这些关键值: ```json { - "api_base_url": "http://192.168.229.128:8000" + "product_code": "stage2-dap", + "channel": "stable", + "current_version": "1.1.0", + "api_base_url": "http://192.168.1.158:18000", + "client_token": "<由服务器 .env 配置>", + "install_root": ". 或 ..", + "platform": "windows", + "arch": "x86_64", + "abi": "msvc" } ``` -## 五、你:业务主程序需要配合什么 +`install_root` 由服务端根据 Launcher 所在位置自动判断:更新系统在软件根目录时写 `.`,在 `bin/` 目录时写 `..`。 -当前安全模式下,业务主程序需要配合两件事: +## 8. 运行数据位置 -1. 接收 `--ticket-file=` 参数,验证并消费一次性启动票据。 -2. 如果收到 `--health-file=` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。 +Windows 运行数据目录: -接入位置: +`%LOCALAPPDATA%\SimCAE\HubUpdateClient\installations\<安装目录SHA256>\` -```text -main / WinMain 开头,创建主窗口之前 -``` +Linux 运行数据目录: -当前仓库里的 `update-client/MainApp/main.cpp` 是接入示例,已经实现: +`$XDG_DATA_HOME/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/` -- 启动票据校验。 -- 本地 License/设备身份校验。 -- 本地策略校验。 -- Manifest 完整性校验。 -- 健康标记写入。 +未设置 `XDG_DATA_HOME` 时通常是: -真正接入业务软件时,把这些启动检查逻辑移植到业务主程序。用户入口应改成 `Launcher.exe`,不要让用户直接双击 `SimCAE.exe`。 +`~/.local/share/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/` -重要:业务主程序校验 ticket 时,必须使用 SDK 当前运行配置里的动态值,不要直接从 `app_config.json` 读取 `device_id`。 +Manifest 缓存保存在运行数据目录下的 `update/manifest_cache`。 -原因是 `app_config.json` 是部署源文件,网页生成时 `device_id` 通常为空;真正的设备 ID 是 `Launcher.exe` 首次向服务端登记后写入当前用户注册表的。`Launcher.exe` 生成 ticket 时使用的是注册表里的真实 `device_id`。如果业务主程序从 `app_config.json` 读取空的 `device_id` 来校验,就会出现: +## 9. 常见问题 -```text -ticket signature, identity, time or nonce invalid -``` - -或者细化后的: - -```text -ticket device_id mismatch -``` - -业务主程序应像 `MainApp/main.cpp` 示例一样使用: - -```cpp -ConfigHelper& config = ConfigHelper::instance(); -TicketHelper::consumeAndVerify( - ticketFilePath, - config.getValue("App", "app_id"), - config.getValue("Update", "device_id"), - config.getValue("App", "current_version"), - config.getValue("App", "launch_token"), - &ticketError); -``` - -也就是说,接入业务主程序时不能只复制一小段 ticket 代码后自己解析 JSON;要么复用 SDK 的 `ConfigHelper` / `TicketHelper`,要么保证业务主程序读取到的 `app_id`、`device_id`、`current_version`、`launch_token` 和 `Launcher.exe` 完全来自同一套运行配置。 - -## 六、你:准备服务端数据 - -首次联调前,服务端至少要准备这些内容: - -1. 创建应用,例如 `simcae`。 -2. 创建渠道,例如 `stable`。 -3. 创建 License。 -4. 发布一个初始版本,例如 `1.0.0`。 -5. 生成客户端配置,并写入 `bin\config\app_config.json`。客户端下次启动时会自动同步到注册表。 -6. 生成 qrc 服务端配置,并写入源码目录 `config\server_config.json` 后重新编译 SDK 程序。 - -为什么必须先发布初始版本:Launcher 启动业务主程序前会做 Manifest 完整性校验。这个 Manifest 是服务端发布版本时生成并签名的清单,用来证明当前本地文件属于一个可信版本。如果没有发布过 `current_version` 对应版本,客户端会提示签名 Manifest 缓存缺失。 - -后台发布版本时,选择整个安装根目录,例如 `D:\SimCAE\`,不要只选择 `D:\SimCAE\bin`。这样服务端会把 `bin/SimCAE.exe`、`Licenses/`、`installerResources/` 等完整结构写进 Manifest。 - -## 七、你:运行和联调 - -基础联调步骤: - -1. 确认服务端正在运行。 -2. 确认 `bin\config\app_config.json` 中 `client_token`、`license_key`、`current_version` 正确,并确认 `Launcher.exe` 已用正确的 `config/server_config.json` 重新编译。 -3. 双击 `bin\Launcher.exe`。 -4. 首次启动时如果 `license_key` 为空,按弹窗输入后台创建的 License;SDK 会把它保存到注册表。 -5. 成功进入业务主程序后,回到后台查看设备、升级日志、下载日志。 -6. 在后台发布更高版本,例如从 `1.0.0` 发布到 `1.0.1`。 -7. 再次启动 `Launcher.exe`,验证升级、健康确认和回滚逻辑。 - -联调目录建议: - -```text -D:\SimCAE_Release\ -C:\Users\<你的用户名>\Desktop\SimCAE_Release\ -``` - -如果安装在 `C:\Program Files\...`、`D:\...` 或其他普通用户不一定可写的目录,SDK 的普通配置值会写入当前用户注册表,不需要修改 `app_config.json`。`client_identity.dat`、`local_state.json`、`version_policy.dat`、更新缓存和 Manifest 缓存也会写入当前用户数据目录,不再默认写到安装目录。 - -Windows 用户数据目录类似:`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\`。因此日常启动、首次授权、保存设备身份、保存策略、防回滚状态和下载缓存不应再触发管理员权限确认框。只有真正要替换安装目录里的 EXE/DLL 等程序文件时,才需要保证安装目录可写,或让安装器/Updater 具备相应权限。 - -## 八、维护者:生成某个产品的最终客户端包 - -SDK 是给接入方开发使用的。最终给用户安装或分发时,可以从已经联调过的 Release 目录生成最终客户端包。 - -在 Windows PowerShell 中执行: - -```powershell -cd C:\Users\admin\Desktop\update-client - -.\scripts\package-client.ps1 ` - -SourceDir .\out\bin\Release ` - -ConfigFile .\config\app_config.json ` - -OutputDir .\dist\UpdateClient ` - -ZipFile .\dist\UpdateClient.zip -``` - -`package-client.ps1` 会检查: - -- 配置文件必填字段是否完整。 -- 主程序、Launcher、Updater、Bootstrap 是否存在。 -- 是否混入 Debug DLL、PDB、ILK。 -- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取: - `%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache`。 - 同时兼容旧版 Release 目录里的 `update\manifest_cache`。 -- 是否存在重复主程序。 - -生成结果: - -```text -dist/ - UpdateClient/ - UpdateClient.zip -``` - -Linux 最终客户端包生成方式: - -```bash -cd /home/laluo/project/update-client - -bash ./scripts/package-client.sh \ - --source-dir /path/to/SimCAE \ - --config-file /path/to/SimCAE/bin/config/app_config.json \ - --output-dir ./dist/UpdateClient-linux \ - --archive ./dist/UpdateClient-linux.tar.gz -``` - -Linux 打包脚本会检查: - -- `app_config.json` 必填字段是否完整。 -- 主程序、Launcher、Updater、Bootstrap 是否存在。 -- 是否混入 Debug 产物。 -- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取: - `$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`, - 未设置 `XDG_DATA_HOME` 时通常是 `~/.local/share/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`。 - 同时兼容旧版 Release 目录里的 `update/manifest_cache`。 -- 是否存在重复主程序。 - -Linux 下如果程序安装在 `/opt`、`/usr/local` 等普通用户不可写目录,升级器无法像 Windows UAC 那样自动提权修改安装目录。正式部署前建议二选一: - -1. 把软件安装到当前用户有写权限的目录,例如用户 home 下的应用目录。 -2. 由安装器创建专用目录和权限,让运行用户对软件目录有写入权限。 - -## 九、常见错误 - -1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。 -2. 首次启动保存配置/状态文件失败:如果目录不可写,SDK 会弹出管理员权限确认框;用户取消或当前账号没有管理员权限时仍会失败。 -3. 首次启动设备登记失败:检查编译进 qrc 的 `config/server_config.json`、`client_token`、`license_key`、服务端 License 状态。 -4. 提示 License 错误或过期:在后台确认 License 是否存在、是否被禁用或删除、是否超过最大设备数。 -5. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。 -6. 提示 signed manifest cache missing:先在后台发布一次 `current_version` 对应版本,并让客户端拿到该版本 Manifest。 -7. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。 -8. 发布失败提示主程序不在根目录:服务端 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 要和平台匹配。Windows 通常是 `bin/SimCAE.exe`;Linux 通常是 `bin/SimCAE`。 -9. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。 +1. 客户门户登录失败:检查客户门户账号是否已激活、客户是否生效、密码是否正确。 +2. 检查更新没有结果:检查后台发布是否已发布、发布包是否可用、产品编码、渠道和平台参数是否一致。 +3. 下载 401:检查发布包中的 `config/app_config.json` 是否由服务端生成,`client_token` 是否和服务器 `.env` 中的 `SIMCAE_CLIENT_TOKEN` 一致。 +4. Manifest 校验失败:检查服务端 Manifest 是否被篡改、发布包 SHA-256 是否和实际文件一致。 +5. 强制签名失败:确认 `manifest_public_key.pem` 与服务端私钥匹配;如果服务端暂未启用签名,测试环境可先把 `require_manifest_signature` 设为 `false`。 +6. 启动主程序失败:检查 `main_executable` 和 `install_root` 是否指向真实文件。 diff --git a/Docs/02-编译环境和第三方依赖说明.md b/Docs/02-编译环境和第三方依赖说明.md index 7288252..04e8353 100644 --- a/Docs/02-编译环境和第三方依赖说明.md +++ b/Docs/02-编译环境和第三方依赖说明.md @@ -1,175 +1,73 @@ -# 客户端编译环境和第三方依赖说明 +# SimCAE Hub 客户端编译环境和第三方依赖说明 -`thirdparty/` 是本机依赖目录,已经被 `.gitignore` 忽略,不会提交到 Git。 +本文说明 `update-client` 的 Qt/C++ 客户端编译环境。客户端保留 Launcher / Updater / Bootstrap 机制,依赖 Qt、CMake 和 OpenSSL。 -当前客户端构建依赖: +## 1. 通用要求 -1. Qt 5.15.2 或兼容的 Qt 5 版本 -2. OpenSSL +| 依赖 | 要求 | +| --- | --- | +| CMake | 建议 3.20 或更高版本 | +| C++ | C++17 | +| Qt | Qt 5,至少需要 Core、Network、Gui、Widgets | +| OpenSSL | 用于 Manifest RSA-SHA256 验签 | +| 编译器 | Windows 推荐 Visual Studio 2022 x64,Linux 推荐 gcc/g++ | -Windows 下推荐使用 Qt 5.15.2 msvc2019_64 和 OpenSSL-Win64;Linux 下使用系统安装的 Qt/OpenSSL 开发包。 +项目已提供 CMake Preset: -先看结论: +| Preset | 平台 | 用途 | +| --- | --- | --- | +| `x64-debug` | Windows | Debug 编译 | +| `x64-release` | Windows | Release 编译 | +| `linux-x64-debug` | Linux | Debug 编译 | +| `linux-x64-release` | Linux | Release 编译 | -- Windows:配置 Qt 环境变量,把 OpenSSL 复制到 `thirdparty/OpenSSL-Win64`。 -- Linux:用 apt 安装 Qt/OpenSSL 开发包。 -- `thirdparty/` 只放本机依赖,不提交 Git。 +## 2. Windows 环境 -## 1. Qt 配置 +建议安装: -### Windows +1. Visual Studio 2022,勾选 Desktop development with C++。 +2. Qt 5 x64,版本可以与当前团队环境保持一致。 +3. CMake。 +4. OpenSSL x64。 -Qt 路径由本机环境变量提供。你需要在 Windows 环境变量里配置 Qt 路径,让 CMake 的 `find_package(Qt5 ...)` 能找到 Qt。 - -推荐配置用户环境变量 `CMAKE_PREFIX_PATH`: +如果 Qt 没有加入环境变量,可以在编译前指定 `CMAKE_PREFIX_PATH` 或 `Qt5_DIR`。示例: ```powershell -[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User") +$env:CMAKE_PREFIX_PATH = "C:\Qt\5.15.2\msvc2019_64" ``` -设置完成后,重新打开 PowerShell 或 Visual Studio。 +OpenSSL 可以放在 `update-client/thirdparty/OpenSSL-Win64`,也可以在配置时通过 `SIMCAE_OPENSSL_ROOT` 指向自定义目录。 -如果只想对当前 PowerShell 窗口临时生效: +## 3. Linux 环境 -```powershell -$env:CMAKE_PREFIX_PATH="C:\Qt\5.15.2\msvc2019_64" -``` - -也可以配置更精确的 `Qt5_DIR`: - -```powershell -[Environment]::SetEnvironmentVariable("Qt5_DIR", "C:\Qt\5.15.2\msvc2019_64\lib\cmake\Qt5", "User") -``` - -`CMAKE_PREFIX_PATH` 和 `Qt5_DIR` 二选一即可,推荐使用 `CMAKE_PREFIX_PATH`。 - -一般不需要把 `C:\Qt\5.15.2\msvc2019_64\bin` 加入 `Path`。项目构建后会通过 `windeployqt` 复制运行所需的 Qt DLL。 - -### Linux - -Linux 下需要安装 Qt5 开发包,让 CMake 能找到 `Qt5::Core`、`Qt5::Network`、`Qt5::Gui`、`Qt5::Widgets`。 - -Ubuntu/Debian 示例: +Ubuntu 示例: ```bash sudo apt update -sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev-tools libssl-dev +sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev qttools5-dev-tools libssl-dev ``` -如果 Qt 安装在自定义目录,可以临时设置: +Linux 下通常直接使用系统 OpenSSL;如需指定自定义 OpenSSL,可用 CMake 变量配置。 -```bash -export CMAKE_PREFIX_PATH=/path/to/Qt/5.x/gcc_64 -``` +## 4. 编译输出 -## 2. OpenSSL 配置 +Windows Release 可执行文件输出目录: -### Windows +`update-client/out/bin/Release` -OpenSSL 默认放在: +Windows CMake 构建目录: -```text -thirdparty/OpenSSL-Win64 -``` +`update-client/out/build/x64-release` -推荐目录结构: +Linux Release 可执行文件输出目录以当前 CMake Preset 和构建脚本为准,SDK 打包时通过 `--source-dir` 指定。 -```text -thirdparty/ - OpenSSL-Win64/ - include/ - openssl/ - lib/ - VC/ - x64/ - MD/ - MDd/ -``` +实际打包 SDK 前,应确认 Release 输出目录中至少包含: -复制命令示例: +1. `Launcher` +2. `Updater` +3. `Bootstrap` +4. 可选的示例 `MainApp` -```powershell -cd C:\Users\admin\Desktop\update-client -mkdir thirdparty -Copy-Item "C:\Program Files\OpenSSL-Win64" ".\thirdparty\OpenSSL-Win64" -Recurse -``` +最终客户软件包里的 `config/app_config.json` 和 `config/manifest_public_key.pem` 由服务端在发布包上传时生成;`server_config.json` 会编译进 EXE 作为兜底地址,不需要进入 SDK 包。 -如果 OpenSSL 不放在 `thirdparty/`,可以在配置 CMake 时手动指定: - -```powershell -cmake -S . -B out\build\x64-Debug ` - -G "Visual Studio 17 2022" ` - -A x64 ` - -DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64" -``` - -### Linux - -Linux 下 CMake 会通过 `find_package(OpenSSL REQUIRED)` 查找系统 OpenSSL。通常安装 `libssl-dev` 即可,不需要 `thirdparty/OpenSSL-Win64`。 - -## 3. Windows 重新配置和编译 - -如果之前配置过 CMake,建议先删除旧缓存: - -```powershell -cd C:\Users\admin\Desktop\update-client -Remove-Item out\build -Recurse -Force -``` - -重新配置: - -```powershell -cmake -S . -B out\build\x64-Debug ` - -G "Visual Studio 17 2022" ` - -A x64 -``` - -编译: - -```powershell -cmake --build out\build\x64-Debug --config Debug -``` - -## 4. Linux 重新配置和编译 - -如果之前配置过 CMake,建议先删除旧缓存: - -```bash -cd ~/project/update-client -rm -rf out/build/linux-x64-debug out/build/linux-x64-release -``` - -Debug 构建: - -```bash -cmake --preset linux-x64-debug -cmake --build --preset linux-x64-debug -``` - -Release 构建: - -```bash -cmake --preset linux-x64-release -cmake --build --preset linux-x64-release -``` - -Linux 构建时会自动使用 `config/app_config.linux.example.json` 作为输出目录里的默认 `config/app_config.json`,可执行程序名不带 `.exe`。 - -## 5. 提交注意事项 - -不要提交下面这些内容: - -```text -thirdparty/ -.vs/ -out/ -build/ -dist/ -App/ -*.exe -*.dll -*.lib -*.pdb -``` - -这些都属于本机依赖、构建产物或打包产物,不应该进 Git。 +SDK 打包和客户端功能验证见 `01-客户端接入打包部署指南.md`。 diff --git a/Launcher/UpdateLogic.cpp b/Launcher/UpdateLogic.cpp index 4a156aa..333013a 100644 --- a/Launcher/UpdateLogic.cpp +++ b/Launcher/UpdateLogic.cpp @@ -1,243 +1,287 @@ -#include "UpdateLogic.h" -#include "ConfigHelper.h" -#include -#include -#include -#include -#include -#include "PolicyHelper.h" -#include "LocalStateHelper.h" - -UpdateLogic::UpdateLogic(QObject* parent) - : QObject(parent) -{ - ConfigHelper& cfg = ConfigHelper::instance(); - m_serverAddr = cfg.getValue("Server", "api_base_url"); - m_appId = cfg.getValue("App", "app_id"); - m_curVer = cfg.getValue("App", "current_version"); - m_channel = cfg.getValue("App", "channel"); - - // Debug print config - qDebug() << "Read server addr:" << m_serverAddr; - qDebug() << "Read app id:" << m_appId; -} - -void UpdateLogic::checkUpdate() -{ - if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) - { - qDebug() << "Config incomplete, abort update check"; - m_needUpdate = false; - return; - } - QString url = m_serverAddr + "/api/v1/update/check"; - QJsonObject body; - body["app_id"] = m_appId; - body["current_version"] = m_curVer; - body["channel"] = m_channel; - const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt(); - body["client_protocol"] = qMax(3, configuredProtocol); - - m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) - { - qDebug() << "Check update HTTP code:" << code; - m_lastStatusCode = code; - m_checkResp = resp; - m_networkOk = (code == 200); - if (code == 200) - { - const QString appDir = QApplication::applicationDirPath(); - PolicyHelper onlinePolicy(appDir); - LocalStateHelper state(appDir); - const bool stateLoaded = state.loadState(); - const bool policyValid = onlinePolicy.loadPolicyObject( - resp.value("policy").toObject(), resp.value("policy_text").toString()); - if (!policyValid || !stateLoaded - || state.isPolicySeqRolledBack(onlinePolicy.policySeq()) - || !onlinePolicy.savePolicy()) - { - qDebug() << "Online policy rejected:" << onlinePolicy.errorString(); - m_networkOk = false; - m_needUpdate = false; - return; - } - state.updateOnlineVerified(onlinePolicy.policySeq()); - if (!state.saveState()) - { - qDebug() << "Cannot persist online policy state"; - m_networkOk = false; - m_needUpdate = false; - return; - } - m_needUpdate = resp["need_update"].toBool(); - m_latestVer = resp["latest_version"].toString(); - qDebug() << "Need update:" << m_needUpdate; - qDebug() << "Latest version:" << m_latestVer; - qDebug() << "Policy seq:" << onlinePolicy.policySeq(); - } - else - { - m_needUpdate = false; - qDebug() << "Check update api failed"; - } - }); -} - -void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) -{ - QString url = m_serverAddr + "/api/v1/update/download-url"; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = ver; - body["version_id"] = verId; - QJsonArray files; - body["files"] = files; - - m_http.postRequest(url, body, [](int code, const QJsonObject& resp) - { - qDebug() << "\n========== File Download Url =========="; - qDebug() << resp["files"].toArray(); - }); -} - -bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version, - int versionId, const QString& cacheDir) -{ - m_error.clear(); - if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) { - m_error = QCoreApplication::translate("UpdateLogic", - "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; - int statusCode = 0; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = version; - body["version_id"] = versionId; - m_http.postRequest(m_serverAddr + "/api/v1/update/manifest", body, - [&](int code, const QJsonObject& resp) { - statusCode = code; - response = resp; - }); +#include "UpdateLogic.h" - 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; - } - - const QJsonObject manifest = response.value("manifest").toObject(); - const QString manifestText = response.value("manifest_text").toString(); - 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.") - .arg(appId, channel, version, QString::number(versionId)); - return false; - } - if (manifest.value("app_id").toString() != appId - || manifest.value("channel").toString() != channel - || manifest.value("version").toString() != version) { - m_error = QCoreApplication::translate("UpdateLogic", - "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); - if (!dir.exists() && !dir.mkpath(".")) { - m_error = QCoreApplication::translate("UpdateLogic", - "Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.") - .arg(cacheDir); - return false; - } - - QJsonObject wrapper; - wrapper["manifest"] = manifest; - wrapper["manifest_text"] = manifestText; - QSaveFile file(dir.filePath("manifest_" + version + ".json")); - const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented); - if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { - m_error = QCoreApplication::translate("UpdateLogic", - "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(); - return true; +#include "ConfigHelper.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QString trimBaseUrl(QString value) +{ + value = value.trimmed(); + while (value.endsWith(QLatin1Char('/'))) + value.chop(1); + return value; } -bool UpdateLogic::refreshGitTagsFile(const QString& outputPath) +void addQueryValue(QUrlQuery& query, const QString& key, const QString& value) +{ + const QString trimmed = value.trimmed(); + if (!trimmed.isEmpty()) + query.addQueryItem(key, trimmed); +} + +QString serverMessage(const QJsonObject& response) +{ + const QString msg = response.value(QStringLiteral("msg")).toString(); + if (!msg.isEmpty()) + return msg; + + const QJsonValue detail = response.value(QStringLiteral("detail")); + if (detail.isObject()) { + const QJsonObject object = detail.toObject(); + const QString detailMsg = object.value(QStringLiteral("msg")).toString(); + if (!detailMsg.isEmpty()) + return detailMsg; + const QString error = object.value(QStringLiteral("error")).toString(); + if (!error.isEmpty()) + return error; + } + return detail.toString(); +} + +QJsonObject responseDataObject(const QJsonObject& response) +{ + return response.value(QStringLiteral("data")).isObject() + ? response.value(QStringLiteral("data")).toObject() + : response; +} + +QString configValue(const QString& key, const QString& fallback = QString()) +{ + const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed(); + return value.isEmpty() ? fallback : value; +} + +} // namespace + +UpdateLogic::UpdateLogic(QObject* parent) + : QObject(parent) +{ + ConfigHelper& cfg = ConfigHelper::instance(); + m_serverAddr = trimBaseUrl(cfg.getValue("Server", "api_base_url")); + m_appId = cfg.getValue("App", "product_code").trimmed(); + if (m_appId.isEmpty()) + m_appId = cfg.getValue("App", "app_id").trimmed(); + m_curVer = cfg.getValue("App", "current_version").trimmed(); + m_channel = cfg.getValue("App", "channel").trimmed(); + if (m_channel.isEmpty()) + m_channel = QStringLiteral("stable"); + + qDebug() << "Read server addr:" << m_serverAddr; + qDebug() << "Read product code:" << m_appId; +} + +void UpdateLogic::checkUpdate() { m_error.clear(); - if (m_serverAddr.isEmpty()) { - m_error = QCoreApplication::translate("UpdateLogic", "Server address is empty."); + m_needUpdate = false; + m_networkOk = false; + m_lastStatusCode = 0; + m_latestVer.clear(); + m_checkResp = QJsonObject(); + + if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty()) + { + m_error = QCoreApplication::translate( + "UpdateLogic", + "Update configuration is incomplete. Server, product_code and current_version are required."); + qDebug() << "Config incomplete, abort update check:" << m_error; + return; + } + if (configValue(QStringLiteral("client_token")).isEmpty()) + { + m_lastStatusCode = 401; + m_error = QCoreApplication::translate( + "UpdateLogic", + "Update configuration is incomplete. client_token is required."); + m_checkResp.insert(QStringLiteral("msg"), m_error); + qDebug() << "Client token missing, abort update check:" << m_error; + return; + } + + QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/authorized-check")); + QUrlQuery query; + addQueryValue(query, QStringLiteral("productCode"), m_appId); + addQueryValue(query, QStringLiteral("currentVersion"), m_curVer); + addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3"))); + addQueryValue(query, QStringLiteral("channel"), m_channel); + addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform"))); + addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch"))); + addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi"))); + url.setQuery(query); + + m_http.getRequest(url.toString(QUrl::FullyEncoded), + [this](int code, const QJsonObject& resp) + { + qDebug() << "Check update HTTP code:" << code; + m_lastStatusCode = code; + m_checkResp = resp; + m_networkOk = (code == 200); + if (code != 200) + { + m_needUpdate = false; + m_error = serverMessage(resp); + qDebug() << "Check update api failed:" << m_error; + return; + } + + const QJsonArray releases = resp.value(QStringLiteral("data")).toArray(); + QJsonObject selected; + for (const QJsonValue& value : releases) { + const QJsonObject release = value.toObject(); + const bool hasPackages = !release.value(QStringLiteral("packages")).toArray().isEmpty(); + const bool hasManifest = release.value(QStringLiteral("manifest")).isObject() + || !release.value(QStringLiteral("manifestUri")).toString().isEmpty(); + if (hasPackages && hasManifest) { + selected = release; + break; + } + } + + if (selected.isEmpty()) { + m_needUpdate = false; + if (!releases.isEmpty()) + m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString(); + qDebug() << "No entitled downloadable update for current client."; + return; + } + + m_checkResp = selected; + m_checkResp.insert(QStringLiteral("need_update"), true); + m_checkResp.insert(QStringLiteral("latest_version"), selected.value(QStringLiteral("version")).toString()); + m_checkResp.insert(QStringLiteral("release_id"), selected.value(QStringLiteral("id")).toString()); + m_needUpdate = true; + m_latestVer = selected.value(QStringLiteral("version")).toString(); + qDebug() << "Need update:" << m_needUpdate; + qDebug() << "Latest version:" << m_latestVer; + qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString(); + }); +} + +void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) +{ + Q_UNUSED(appId); + Q_UNUSED(channel); + Q_UNUSED(ver); + Q_UNUSED(verId); + qDebug() << "SimCAE Hub manifest already contains authorized package download URLs."; +} + +bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version, + int versionId, const QString& cacheDir) +{ + Q_UNUSED(versionId); + m_error.clear(); + if (appId.isEmpty() || channel.isEmpty() || version.isEmpty()) { + m_error = QCoreApplication::translate( + "UpdateLogic", + "Current version manifest identity is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.") + .arg(appId, channel, version); return false; } + QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest")); + QUrlQuery query; + addQueryValue(query, QStringLiteral("productCode"), appId); + addQueryValue(query, QStringLiteral("version"), version); + addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3"))); + addQueryValue(query, QStringLiteral("channel"), channel); + addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform"))); + addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch"))); + addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi"))); + url.setQuery(query); + QJsonObject response; int statusCode = 0; - QJsonObject body; - body["app_id"] = m_appId; - body["channel"] = m_channel; - m_http.postRequest(m_serverAddr + "/api/v1/git/tags", body, + m_http.getRequest(url.toString(QUrl::FullyEncoded), [&](int code, const QJsonObject& resp) { statusCode = code; response = resp; }); if (statusCode != 200) { - const QJsonValue detail = response.value("detail"); - const QString message = detail.isObject() - ? detail.toObject().value("msg").toString() - : detail.toString(); - m_error = message.isEmpty() - ? QCoreApplication::translate("UpdateLogic", "Git tags request failed (HTTP %1).").arg(statusCode) - : message; + const QString message = serverMessage(response); + m_error = QCoreApplication::translate( + "UpdateLogic", + "Cannot download manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. Product: %2, channel: %3, version: %4.%5") + .arg(QString::number(statusCode), appId, channel, version, + message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message)); return false; } - const QString tagsText = response.value("tags_text").toString(); - if (tagsText.isEmpty()) { - m_error = QCoreApplication::translate("UpdateLogic", "Git tags response is empty."); + QJsonObject wrapper = responseDataObject(response); + const QJsonObject manifest = wrapper.value(QStringLiteral("manifest")).toObject(); + QString manifestText = wrapper.value(QStringLiteral("manifestText")).toString(); + if (manifestText.isEmpty()) + manifestText = wrapper.value(QStringLiteral("manifest_text")).toString(); + if (manifest.isEmpty() || manifestText.isEmpty()) { + m_error = QCoreApplication::translate( + "UpdateLogic", + "The manifest response for the current local version is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.") + .arg(appId, channel, version); + return false; + } + const QString manifestProduct = manifest.value(QStringLiteral("productCode")).toString( + manifest.value(QStringLiteral("app_id")).toString()); + if (manifestProduct != appId + || manifest.value(QStringLiteral("channel")).toString() != channel + || manifest.value(QStringLiteral("version")).toString() != version) { + m_error = QCoreApplication::translate( + "UpdateLogic", + "The manifest identity does not match the current local version. Stage: cache current version manifest. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.") + .arg(appId, channel, version, + manifestProduct, + manifest.value(QStringLiteral("channel")).toString(), + manifest.value(QStringLiteral("version")).toString()); return false; } - QString writeError; - if (!ConfigHelper::writeFileWithElevationIfNeeded(outputPath, tagsText.toUtf8(), &writeError)) { - m_error = QCoreApplication::translate("UpdateLogic", "Cannot write Git tags file: %1").arg(writeError); + QDir dir(cacheDir); + if (!dir.exists() && !dir.mkpath(".")) { + m_error = QCoreApplication::translate( + "UpdateLogic", + "Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.") + .arg(cacheDir); return false; } - qDebug() << "Git tags file refreshed:" << outputPath; + + wrapper.insert(QStringLiteral("manifest"), manifest); + wrapper.insert(QStringLiteral("manifestText"), manifestText); + wrapper.insert(QStringLiteral("manifest_text"), manifestText); + QSaveFile file(dir.filePath(QStringLiteral("manifest_") + version + QStringLiteral(".json"))); + const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { + m_error = QCoreApplication::translate( + "UpdateLogic", + "Cannot save 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(); + return true; +} + +bool UpdateLogic::refreshGitTagsFile(const QString& outputPath) +{ + Q_UNUSED(outputPath); + m_error.clear(); + qDebug() << "Git tag export is not part of the current SimCAE Hub admin pages; skipped."; return true; } void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) { - QString url = m_serverAddr + "/api/v1/update/report"; - QJsonObject body; - body["app_id"] = m_appId; - body["device_id"] = deviceId; - body["from_version"] = fromVer; - body["to_version"] = toVer; - body["result"] = success ? "success" : "fail"; - - m_http.postRequest(url, body, [](int code, const QJsonObject& resp) - { - qDebug() << "\n========== Update Report Result =========="; - qDebug() << resp; - }); -} + Q_UNUSED(deviceId); + Q_UNUSED(fromVer); + Q_UNUSED(toVer); + Q_UNUSED(success); + qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped."; +} diff --git a/Launcher/UpdateLogic.h b/Launcher/UpdateLogic.h index 028c4fd..e3d2f10 100644 --- a/Launcher/UpdateLogic.h +++ b/Launcher/UpdateLogic.h @@ -24,8 +24,9 @@ public: int lastStatusCode() const { return m_lastStatusCode; } QString errorString() const { return m_error; } - QString getAppId() const { return m_appId; } - QString getChannel() const { return m_channel; } + QString getAppId() const { return m_appId; } + QString getProductCode() const { return m_appId; } + QString getChannel() const { return m_channel; } private: HttpHelper m_http; diff --git a/Launcher/main.cpp b/Launcher/main.cpp index 2f7dd87..7ebdcde 100644 --- a/Launcher/main.cpp +++ b/Launcher/main.cpp @@ -1,28 +1,75 @@ #include #include #include +#include +#include #include #include #include -#include -#include #include +#include + #include "UpdateLogic.h" -#include "../Common/ConfigHelper.h" -#include "../Common/PolicyHelper.h" -#include "../Common/LocalStateHelper.h" -#include "../Common/TicketHelper.h" -#include "../Common/DeviceIdentityHelper.h" -#include -#include -#include - +#include "../Common/ConfigHelper.h" +#include "../Common/TicketHelper.h" + +namespace { + +QString configValue(const QString& key, const QString& fallback = QString()) +{ + const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed(); + return value.isEmpty() ? fallback : value; +} + +QString productCode() +{ + return configValue(QStringLiteral("product_code"), + configValue(QStringLiteral("app_id"))); +} + +QString ensureDeviceId() +{ + ConfigHelper& config = ConfigHelper::instance(); + QString deviceId = config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")).trimmed(); + if (!deviceId.isEmpty()) + return deviceId; + + deviceId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")).trimmed(); + if (deviceId.isEmpty()) + deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces); + + config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), deviceId); + config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), deviceId); + return deviceId; +} + +QString authFailureMessage(const QJsonObject& response, const QString& fallback) +{ + const QString msg = response.value(QStringLiteral("msg")).toString(); + if (!msg.isEmpty()) + return msg; + const QJsonValue detail = response.value(QStringLiteral("detail")); + if (detail.isObject()) { + const QJsonObject object = detail.toObject(); + const QString detailMsg = object.value(QStringLiteral("msg")).toString(); + if (!detailMsg.isEmpty()) + return detailMsg; + const QString error = object.value(QStringLiteral("error")).toString(); + if (!error.isEmpty()) + return error; + } + const QString detailText = detail.toString(); + return detailText.isEmpty() ? fallback : detailText; +} + +} // namespace + int main(int argc, char* argv[]) { QApplication app(argc, argv); - QApplication::setApplicationName("Marsco Launcher"); + QApplication::setApplicationName("SimCAE Launcher"); QTranslator translator; - if (translator.load(":/i18n/update-client_zh_CN.qm")) + if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm"))) app.installTranslator(&translator); const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); @@ -30,188 +77,45 @@ int main(int argc, char* argv[]) return elevatedWriteExitCode; QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0); - progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher")); - progress.setCancelButton(nullptr); - progress.setWindowModality(Qt::ApplicationModal); - progress.setMinimumDuration(0); - progress.setAutoClose(false); - progress.show(); - QApplication::processEvents(); - - const QString appDir = QApplication::applicationDirPath(); + progress.setWindowTitle(QCoreApplication::translate("Launcher", "SimCAE Launcher")); + progress.setCancelButton(nullptr); + progress.setWindowModality(Qt::ApplicationModal); + progress.setMinimumDuration(0); + progress.setAutoClose(false); + progress.show(); + QApplication::processEvents(); + ConfigHelper& config = ConfigHelper::instance(); - const auto isLicenseError = [](const QString& errorText) { - const QString text = errorText.toLower(); - return text.contains("license") - || text.contains("authorization") - || text.contains("expired") - || text.contains("device limit") - || text.contains("bound to another license"); - }; - const auto clearDeviceCredential = [&]() { - ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath()); - config.setValue("Update", "device_id", QString()); - }; - QString licenseKey = config.getValue("License", "license_key").trimmed(); - auto promptAndSaveLicense = [&](const QString& reason) -> QString { - QString promptReason = reason; - while (true) { - progress.close(); - bool accepted = false; - const QString appName = config.getValue("App", "app_name").trimmed(); - const QString prompt = promptReason.trimmed().isEmpty() - ? QCoreApplication::translate("Launcher", "Please enter the License for %1:") - .arg(appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName) - : QCoreApplication::translate("Launcher", "%1\n\nPlease enter a new License for %2:") - .arg(promptReason, appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName); - licenseKey = QInputDialog::getText( - nullptr, - QCoreApplication::translate("Launcher", "Enter License"), - prompt, - QLineEdit::Normal, - QString(), - &accepted - ).trimmed(); - if (!accepted) { - QMessageBox::information(nullptr, - QCoreApplication::translate("Launcher", "License Required"), - QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch.")); - return QString(); - } - if (licenseKey.isEmpty()) { - QMessageBox::warning(nullptr, - QCoreApplication::translate("Launcher", "License Cannot Be Empty"), - QCoreApplication::translate("Launcher", "Please paste the License created in the admin page.")); - promptReason.clear(); - continue; - } - if (!config.setValue("License", "license_key", licenseKey)) { - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Failed to Save License"), - QCoreApplication::translate("Launcher", "Cannot write the configuration file:\n%1\n%2").arg(config.configPath(), config.lastError())); - return QString(); - } - progress.show(); - progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License...")); - QApplication::processEvents(); - return licenseKey; - } - }; - - while (true) { - // License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。 - // 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。 - if (licenseKey.isEmpty()) { - licenseKey = promptAndSaveLicense(QString()); - if (licenseKey.isEmpty()) return 0; - } - DeviceIdentityHelper identity(appDir); - if (identity.ensureIssued(config.getValue("Server", "api_base_url"), - config.getValue("Server", "client_token"), - config.getValue("App", "app_id"), - config.getValue("App", "channel"), - licenseKey)) { - break; - } - const QString error = identity.errorString(); - if (!isLicenseError(error)) { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Device Authentication Failed"), - error); - return -1; - } - clearDeviceCredential(); - licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error)); - if (licenseKey.isEmpty()) return 0; - } - - UpdateLogic logic; - logic.checkUpdate(); - - const bool needUpdate = logic.getNeedUpdate(); - const bool networkOk = logic.isNetworkOk(); - const QString latestVer = logic.getLatestVersion(); + const QString appDir = QApplication::applicationDirPath(); + + progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates...")); + QApplication::processEvents(); + UpdateLogic logic; + logic.checkUpdate(); + + const bool needUpdate = logic.getNeedUpdate(); + const bool networkOk = logic.isNetworkOk(); + const QString latestVer = logic.getLatestVersion(); const QJsonObject response = logic.getCheckResult(); - const int targetVersionId = response.value("version_id").toInt(); - const QString appId = logic.getAppId(); + const QString releaseId = response.value(QStringLiteral("release_id")).toString( + response.value(QStringLiteral("id")).toString()); + const QString appId = logic.getProductCode(); const QString channel = logic.getChannel(); - const QString launchToken = config.getValue("App", "launch_token"); - const QString currentVersion = config.getValue("App", "current_version"); - - if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { - progress.close(); - const QJsonValue detail = response.value("detail"); - const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); - const QString displayMessage = message.isEmpty() - ? QCoreApplication::translate("Launcher", "The device or License authorization is invalid.") - : message; - if (isLicenseError(displayMessage)) { - clearDeviceCredential(); - const QString newLicense = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current authorization was rejected by the server: %1").arg(displayMessage)); - if (!newLicense.isEmpty()) { - progress.close(); - QMessageBox::information(nullptr, - QCoreApplication::translate("Launcher", "License Saved"), - QCoreApplication::translate("Launcher", "Please restart Launcher to complete device authorization and update checking.")); - } - return 0; - } - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Authorization Rejected"), - displayMessage); - return -1; - } - + const QString launchToken = config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")); + const QString currentVersion = config.getValue(QStringLiteral("App"), QStringLiteral("current_version")); + const QString deviceId = ensureDeviceId(); + const auto configuredName = [&](const QString& key, const QString& fallback) { return ConfigHelper::executableNameForCurrentPlatform( - config.getValue("Runtime", key), fallback); + config.getValue(QStringLiteral("Runtime"), key), fallback); }; - const QString mainExecutable = configuredName("main_executable", "MainApp"); - const QString updaterExecutable = configuredName("updater_executable", "Updater"); + const QString mainExecutable = configuredName(QStringLiteral("main_executable"), QStringLiteral("MainApp")); + const QString updaterExecutable = configuredName(QStringLiteral("updater_executable"), QStringLiteral("Updater")); const QString mainAppPath = QDir(appDir).filePath(mainExecutable); const QString updaterPath = QDir(appDir).filePath(updaterExecutable); - const auto importOfflinePackage = [&]() { - const QString package = QFileDialog::getOpenFileName(nullptr, - QCoreApplication::translate("Launcher", "Select Offline Update Package"), - QString(), - QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)")); - 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"); - if (QCoreApplication::arguments().contains("--import-offline")) { - progress.close(); - if (!importOfflinePackage()) - QMessageBox::information(nullptr, - QCoreApplication::translate("Launcher", "Offline Update"), - QCoreApplication::translate("Launcher", "No offline update package was selected.")); - return 0; - } - + QString mainStartupError; const auto startMainApp = [&]() { - // 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。 - // 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。 mainStartupError.clear(); if (!QFileInfo::exists(mainAppPath)) { mainStartupError = QCoreApplication::translate( @@ -220,9 +124,10 @@ int main(int argc, char* argv[]) .arg(mainAppPath); return false; } + QString ticketPath; QString ticketError; - if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, + if (!TicketHelper::createTicket(appId, deviceId, currentVersion, launchToken, &ticketPath, &ticketError)) { qDebug() << "Cannot create launch ticket:" << ticketError; mainStartupError = QCoreApplication::translate( @@ -231,8 +136,9 @@ int main(int argc, char* argv[]) .arg(mainAppPath, ticketError); return false; } + const bool started = QProcess::startDetached(mainAppPath, - QStringList{QString("--ticket-file=%1").arg(ticketPath)}); + QStringList{QStringLiteral("--ticket-file=%1").arg(ticketPath)}); if (!started) { QFile::remove(ticketPath); mainStartupError = QCoreApplication::translate( @@ -242,97 +148,33 @@ int main(int argc, char* argv[]) } return started; }; - - progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy...")); - QApplication::processEvents(); - - PolicyHelper policy(appDir); - if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Cannot Start"), - QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString())); - return -1; - } - if (policy.isExpired()) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Cannot Start"), - QCoreApplication::translate("Launcher", "The local version policy has expired. Please connect to the network or contact the administrator.")); - return -1; - } - if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Current Version Cannot Run"), - policy.message().isEmpty() ? QCoreApplication::translate("Launcher", "The current version %1 has been disabled by the administrator.").arg(currentVersion) - : policy.message()); - return -1; - } - - LocalStateHelper state(appDir); - if (!state.loadState()) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Cannot Start"), - QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString())); - return -1; - } - if (state.isPolicySeqRolledBack(policy.policySeq())) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Security Check Failed"), - QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected. Startup has been blocked.")); - return -1; - } - if (state.isSystemTimeRewound()) - { + + if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { progress.close(); QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Security Check Failed"), - QCoreApplication::translate("Launcher", "A possible system time rollback was detected. Startup has been blocked.")); + QCoreApplication::translate("Launcher", "Update Client Rejected"), + authFailureMessage(response, + QCoreApplication::translate("Launcher", "The update client token is missing or invalid. Please download a valid package from the customer portal."))); return -1; - } - - if (networkOk && policy.gitTagsEnabled()) - { - progress.setLabelText(QCoreApplication::translate("Launcher", "Generating Git tag list...")); - QApplication::processEvents(); - const QString tagsPath = QDir(appDir).filePath("tags.txt"); - if (!logic.refreshGitTagsFile(tagsPath)) - { - progress.close(); - QMessageBox::warning(nullptr, - QCoreApplication::translate("Launcher", "Git Tag List Failed"), - QCoreApplication::translate("Launcher", "Cannot generate tags.txt, but startup will continue:\n%1").arg(logic.errorString())); - progress.show(); - QApplication::processEvents(); - } + } else if (!networkOk) { + progress.close(); + QMessageBox::warning(nullptr, + QCoreApplication::translate("Launcher", "Update Server Unavailable"), + QCoreApplication::translate("Launcher", "Cannot connect to the update server. The installed application will be started without downloading an update.")); + progress.show(); } if (networkOk && needUpdate) { progress.close(); - const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; - const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback") - : (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available")); - const QString prompt = policy.message().isEmpty() - ? (rollbackOperation - ? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer) - : QCoreApplication::translate("Launcher", "Version %1 is available. Update now?").arg(latestVer)) - : policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer); - bool accepted = true; - if (policy.forceUpdate()) { - QMessageBox::information(nullptr, dialogTitle, prompt); - } else { - accepted = QMessageBox::question(nullptr, dialogTitle, prompt, - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; - } + const QString prompt = QCoreApplication::translate( + "Launcher", + "Version %1 is available. Update now?").arg(latestVer); + const bool accepted = QMessageBox::question(nullptr, + QCoreApplication::translate("Launcher", "New Version Available"), + prompt, + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) == QMessageBox::Yes; if (!accepted) { if (!startMainApp()) { QMessageBox::critical(nullptr, @@ -344,7 +186,14 @@ int main(int argc, char* argv[]) } return 0; } - const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; + + const QStringList updaterArgs{ + appId, + channel, + latestVer, + QStringLiteral("0"), + QStringLiteral("--release-id=%1").arg(releaseId) + }; if (!QFileInfo::exists(updaterPath)) { QMessageBox::critical(nullptr, @@ -368,49 +217,10 @@ int main(int argc, char* argv[]) return 0; } - if (networkOk && !needUpdate && targetVersionId > 0 && latestVer == currentVersion) - { - progress.setLabelText(QCoreApplication::translate("Launcher", "Caching signed version manifest...")); - QApplication::processEvents(); - const QString manifestCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("manifest_cache"); - if (!logic.cacheManifest(appId, channel, currentVersion, targetVersionId, manifestCacheDir)) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Manifest Cache Failed"), - QCoreApplication::translate("Launcher", "Cannot cache the signed manifest for the current version: %1").arg(logic.errorString())); - return -1; - } - } - - if (!networkOk) { - progress.close(); - if (QMessageBox::question(nullptr, - QCoreApplication::translate("Launcher", "Server Unavailable"), - QCoreApplication::translate("Launcher", "Cannot connect to the update server. Import an offline update package?"), - QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { - if (!importOfflinePackage()) - QMessageBox::information(nullptr, - QCoreApplication::translate("Launcher", "Offline Update"), - QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started.")); - return 0; - } - progress.show(); - } - - if (!networkOk && !policy.isOfflineAllowed()) - { - progress.close(); - QMessageBox::critical(nullptr, - QCoreApplication::translate("Launcher", "Network Unavailable"), - QCoreApplication::translate("Launcher", "Cannot connect to the update server, and the current policy does not allow offline startup.")); - return -1; - } - progress.setLabelText(networkOk ? QCoreApplication::translate("Launcher", "The application is up to date. Starting...") - : QCoreApplication::translate("Launcher", "Offline mode is active. Starting...")); - QApplication::processEvents(); + : QCoreApplication::translate("Launcher", "Offline startup. Starting...")); + QApplication::processEvents(); if (!startMainApp()) { progress.close(); @@ -421,6 +231,6 @@ int main(int argc, char* argv[]) : mainStartupError); return -1; } - progress.close(); - return 0; -} + progress.close(); + return 0; +} diff --git a/MainApp/MainWindow.cpp b/MainApp/MainWindow.cpp index 77f4c58..8c2246e 100644 --- a/MainApp/MainWindow.cpp +++ b/MainApp/MainWindow.cpp @@ -1,72 +1,69 @@ -#include "MainWindow.h" -#include -#include -#include -#include -#include -#include -#include "../Common/PolicyHelper.h" -#include "../Common/ConfigHelper.h" - -static QString readDllVersion(const QString& dllPath) -{ - QFile file(dllPath); - if (!file.exists()) - return "missing"; - - if (!file.open(QIODevice::ReadOnly)) - return "unreadable"; - - QByteArray data = file.read(1024); - file.close(); - return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8)); -} - -MainWindow::MainWindow(QWidget* parent) - : QWidget(parent) -{ - this->setWindowTitle("Marsco Demo MainApp"); - this->resize(600, 400); - - QString appVersion = ConfigHelper::instance().getValue("App", "current_version"); - if (appVersion.isEmpty()) - appVersion = "unknown"; - - QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); - - PolicyHelper policy(QApplication::applicationDirPath()); - QString policyResult; - if (!policy.loadPolicy("config/version_policy.dat")) - { - policyResult = "policy missing"; - } - else if (!policy.isValid()) - { - policyResult = "policy invalid"; - } - else if (!policy.isVersionAllowed(appVersion)) - { - policyResult = "version disabled"; - } - else if (policy.isExpired()) - { - policyResult = "policy expired"; - } - else - { - policyResult = "policy ok"; - } - - QVBoxLayout* layout = new QVBoxLayout(this); - QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3") - .arg(appVersion) - .arg(dllVersion) - .arg(policyResult)); - QFont font = label->font(); - font.setPointSize(14); - label->setFont(font); - label->setAlignment(Qt::AlignCenter); - - layout->addWidget(label); - this->setLayout(layout); -} \ No newline at end of file +#include "MainWindow.h" + +#include +#include +#include +#include +#include +#include + +#include "../Common/ConfigHelper.h" + +namespace { + +QString configValue(const QString& key, const QString& fallback = QString()) +{ + const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed(); + return value.isEmpty() ? fallback : value; +} + +QString readDllVersion(const QString& dllPath) +{ + QFile file(dllPath); + if (!file.exists()) + return QStringLiteral("missing"); + + if (!file.open(QIODevice::ReadOnly)) + return QStringLiteral("unreadable"); + + const QByteArray data = file.read(1024); + file.close(); + return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8)); +} + +} // namespace + +MainWindow::MainWindow(QWidget* parent) + : QWidget(parent) +{ + this->setWindowTitle("SimCAE Demo MainApp"); + this->resize(600, 400); + + const QString appVersion = configValue(QStringLiteral("current_version"), QStringLiteral("unknown")); + const QString product = configValue(QStringLiteral("product_code"), + configValue(QStringLiteral("app_id"), QStringLiteral("unknown"))); + const QString channel = configValue(QStringLiteral("channel"), QStringLiteral("stable")); + const QString apiBaseUrl = configValue(QStringLiteral("api_base_url"), QStringLiteral("not configured")); + const QString tokenState = configValue(QStringLiteral("client_token")).isEmpty() + ? QStringLiteral("missing") + : QStringLiteral("configured"); + const QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); + + QVBoxLayout* layout = new QVBoxLayout(this); + QLabel* label = new QLabel(QString( + "Software Running Successfully\n" + "Product: %1\n" + "Version: %2\n" + "Channel: %3\n" + "Server: %4\n" + "Update Client Token: %5\n" + "DLL Version: %6") + .arg(product, appVersion, channel, apiBaseUrl, tokenState, dllVersion)); + QFont font = label->font(); + font.setPointSize(13); + label->setFont(font); + label->setAlignment(Qt::AlignCenter); + + layout->addWidget(label); + this->setLayout(layout); +} diff --git a/MainApp/main.cpp b/MainApp/main.cpp index 71a7624..26b473b 100644 --- a/MainApp/main.cpp +++ b/MainApp/main.cpp @@ -1,135 +1,115 @@ -#include -#include -#include -#include -#include -#include -#include -#include "MainWindow.h" -#include "../Common/ConfigHelper.h" -#include "../Common/PolicyHelper.h" -#include "../Common/LocalStateHelper.h" -#include "../Common/TicketHelper.h" -#include "../Common/IntegrityHelper.h" -#include "../Common/DeviceIdentityHelper.h" - -int main(int argc, char* argv[]) -{ - QApplication a(argc, argv); - QTranslator translator; - if (translator.load(":/i18n/update-client_zh_CN.qm")) - a.installTranslator(&translator); - - const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); - if (elevatedWriteExitCode >= 0) - return elevatedWriteExitCode; - - qDebug() << Qt::endl << "entered main app" << Qt::endl; - +#include +#include +#include +#include +#include +#include +#include + +#include "MainWindow.h" +#include "../Common/ConfigHelper.h" +#include "../Common/IntegrityHelper.h" +#include "../Common/TicketHelper.h" + +namespace { + +QString configValue(const QString& key, const QString& fallback = QString()) +{ + const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed(); + return value.isEmpty() ? fallback : value; +} + +QString productCode() +{ + return configValue(QStringLiteral("product_code"), + configValue(QStringLiteral("app_id"))); +} + +bool configFlag(const QString& key) +{ + const QString value = configValue(key).toLower(); + return value == QStringLiteral("true") + || value == QStringLiteral("1") + || value == QStringLiteral("yes") + || value == QStringLiteral("on"); +} + +} // namespace + +int main(int argc, char* argv[]) +{ + QApplication a(argc, argv); + QTranslator translator; + if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm"))) + a.installTranslator(&translator); + + const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); + if (elevatedWriteExitCode >= 0) + return elevatedWriteExitCode; + + qDebug() << Qt::endl << "entered main app" << Qt::endl; + ConfigHelper& config = ConfigHelper::instance(); - QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); - launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher"); - QString ticketFilePath; - QString healthFilePath; - for (int i = 1; i < argc; ++i) - { - const QString arg(argv[i]); - if (arg.startsWith("--ticket-file=")) - ticketFilePath = arg.mid(QString("--ticket-file=").size()); - else if (arg.startsWith("--health-file=")) - healthFilePath = arg.mid(QString("--health-file=").size()); - } - - QString ticketError; - if (ticketFilePath.isEmpty() - || !TicketHelper::consumeAndVerify(ticketFilePath, - config.getValue("App", "app_id"), config.getValue("Update", "device_id"), - config.getValue("App", "current_version"), config.getValue("App", "launch_token"), - &ticketError)) - { - QMessageBox::critical(nullptr, "Startup Restriction", - QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") - .arg(ticketError, launcherExecutable)); - return -1; - } - - QString appDir = QApplication::applicationDirPath(); - const QString installRoot = config.installRoot(); - DeviceIdentityHelper identity(appDir); - if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) - { - QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString())); - return -1; - } - PolicyHelper policy(appDir); - if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) - { - QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString())); - return -1; - } - - if (policy.isExpired()) - { - QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application."); - return -1; - } - - LocalStateHelper state(appDir); - if (!state.loadState()) - { - QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString())); - return -1; - } - - if (state.isPolicySeqRolledBack(policy.policySeq())) - { - QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked."); - return -1; - } - - if (state.isSystemTimeRewound()) - { - QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked."); - return -1; - } - - IntegrityHelper integrity(installRoot); - if (!integrity.verifyInstalledVersion( - config.getValue("App", "app_id"), config.getValue("App", "channel"), - config.getValue("App", "current_version"))) + QString launcherExecutable = config.getValue(QStringLiteral("Runtime"), QStringLiteral("launcher_executable")).trimmed(); + launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, QStringLiteral("Launcher")); + + QString ticketFilePath; + QString healthFilePath; + for (int i = 1; i < argc; ++i) { - QMessageBox::critical(nullptr, "Integrity Check Failed", - QString("Startup blocked because the installed files failed local signed Manifest verification.\n" - "This is not a download check; it means the current installation directory does not match the signed Manifest cache for this version.\n\n" - "Details:\n%1") - .arg(integrity.errorString())); + const QString arg(argv[i]); + if (arg.startsWith(QStringLiteral("--ticket-file="))) + ticketFilePath = arg.mid(QStringLiteral("--ticket-file=").size()); + else if (arg.startsWith(QStringLiteral("--health-file="))) + healthFilePath = arg.mid(QStringLiteral("--health-file=").size()); + } + + QString ticketError; + if (ticketFilePath.isEmpty() + || !TicketHelper::consumeAndVerify(ticketFilePath, + productCode(), config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")), + config.getValue(QStringLiteral("App"), QStringLiteral("current_version")), + config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")), + &ticketError)) + { + QMessageBox::critical(nullptr, "Startup Restriction", + QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") + .arg(ticketError, launcherExecutable)); return -1; } - - MainWindow w; - w.show(); - - state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq()); - if (!state.saveState()) - { - QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString())); - return -1; - } - - if (!healthFilePath.isEmpty()) - { - QDir().mkpath(QFileInfo(healthFilePath).path()); - QSaveFile healthFile(healthFilePath); - const QByteArray healthy("ok\n"); - if (!healthFile.open(QIODevice::WriteOnly) - || healthFile.write(healthy) != healthy.size() - || !healthFile.commit()) - { - QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file."); - return -1; - } - } - - qDebug() << "Main program MainApp is running normally"; - return a.exec(); -} + + const QString installRoot = config.installRoot(); + if (configFlag(QStringLiteral("verify_installed_on_start"))) { + IntegrityHelper integrity(installRoot); + if (!integrity.verifyInstalledVersion( + productCode(), + config.getValue(QStringLiteral("App"), QStringLiteral("channel")), + config.getValue(QStringLiteral("App"), QStringLiteral("current_version")))) + { + QMessageBox::critical(nullptr, "Integrity Check Failed", + QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1") + .arg(integrity.errorString())); + return -1; + } + } + + if (!healthFilePath.isEmpty()) + { + QDir().mkpath(QFileInfo(healthFilePath).path()); + QSaveFile healthFile(healthFilePath); + const QByteArray healthy("ok\n"); + if (!healthFile.open(QIODevice::WriteOnly) + || healthFile.write(healthy) != healthy.size() + || !healthFile.commit()) + { + QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file."); + return -1; + } + } + + MainWindow w; + w.show(); + + qDebug() << "Main program MainApp is running normally"; + return a.exec(); +} diff --git a/Updater/UpdaterLogic.cpp b/Updater/UpdaterLogic.cpp index 5bfa40a..aad3fce 100644 --- a/Updater/UpdaterLogic.cpp +++ b/Updater/UpdaterLogic.cpp @@ -13,10 +13,13 @@ #include #include #include -#include -#include -#include -#include "ConfigHelper.h" +#include +#include +#include +#include +#include +#include "ConfigHelper.h" +#include "UpdatePathPolicy.h" #ifdef HAVE_OPENSSL #include @@ -25,15 +28,44 @@ #include #endif -UpdaterLogic::UpdaterLogic(QObject* parent) - : QObject(parent) +namespace { + +QString trimBaseUrl(QString value) { - m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url"); + value = value.trimmed(); + while (value.endsWith(QLatin1Char('/'))) + value.chop(1); + return value; +} + +QString configValue(const QString& key, const QString& fallback = QString()) +{ + const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed(); + return value.isEmpty() ? fallback : value; +} + +bool configFlag(const QString& key) +{ + const QString value = configValue(key).toLower(); + return value == QStringLiteral("true") + || value == QStringLiteral("1") + || value == QStringLiteral("yes") + || value == QStringLiteral("on"); +} + +void addQueryValue(QUrlQuery& query, const QString& key, const QString& value) +{ + const QString trimmed = value.trimmed(); + if (!trimmed.isEmpty()) + query.addQueryItem(key, trimmed); } -namespace { QString serverDetailMessage(const QJsonObject& response) { + const QString msg = response.value(QStringLiteral("msg")).toString(); + if (!msg.isEmpty()) + return msg; + const QJsonValue detail = response.value(QStringLiteral("detail")); if (detail.isObject()) { const QJsonObject obj = detail.toObject(); @@ -44,52 +76,115 @@ QString serverDetailMessage(const QJsonObject& response) } return detail.toString(); } + +qint64 manifestFileSize(const QJsonObject& file) +{ + if (file.contains(QStringLiteral("sizeBytes"))) + return file.value(QStringLiteral("sizeBytes")).toVariant().toLongLong(); + if (file.contains(QStringLiteral("size"))) + return file.value(QStringLiteral("size")).toVariant().toLongLong(); + return -1; } - -void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId) + +QString absoluteDownloadUrl(const QString& baseUrl, const QString& downloadUrl) +{ + const QString trimmed = downloadUrl.trimmed(); + if (trimmed.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive) + || trimmed.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive)) + return trimmed; + if (trimmed.startsWith(QLatin1Char('/'))) + return trimBaseUrl(baseUrl) + trimmed; + return trimBaseUrl(baseUrl) + QLatin1Char('/') + trimmed; +} +} + +UpdaterLogic::UpdaterLogic(QObject* parent) + : QObject(parent) +{ + m_serverAddr = trimBaseUrl(ConfigHelper::instance().getValue("Server", "api_base_url")); +} + +void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, + int versionId, const QString& releaseId) { // Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。 // Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。 m_error.clear(); - QString url = m_serverAddr + "/api/v1/update/manifest"; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = targetVer; - body["version_id"] = versionId; - - m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp) + Q_UNUSED(versionId); + m_manifestSha256.clear(); + m_manifestSignature.clear(); + m_manifestSignatureAlg.clear(); + m_manifestKeyId.clear(); + m_manifestSigned = false; + m_fileItems.clear(); + + QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest")); + QUrlQuery query; + addQueryValue(query, QStringLiteral("releaseId"), releaseId); + addQueryValue(query, QStringLiteral("productCode"), appId); + addQueryValue(query, QStringLiteral("version"), targetVer); + addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3"))); + addQueryValue(query, QStringLiteral("channel"), channel); + addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform"))); + addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch"))); + addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi"))); + url.setQuery(query); + + m_http.getRequest(url.toString(QUrl::FullyEncoded), + [this, appId, channel, targetVer, releaseId](int code, const QJsonObject& resp) { - qDebug() << "Manifest API returned code:" << code; - m_manifest = QJsonObject(); - m_manifestText.clear(); - - if (code == 200) - { - if (resp.contains("manifest_text") && resp.contains("manifest")) - { - m_manifestText = resp["manifest_text"].toString(); - m_manifest = resp["manifest"].toObject(); - qDebug() << "Received manifest version:" << m_manifest.value("version").toString(); - m_fileItems.clear(); - QJsonArray files = m_manifest.value("files").toArray(); + qDebug() << "Manifest API returned code:" << code; + m_manifest = QJsonObject(); + m_manifestText.clear(); + m_manifestSha256.clear(); + m_manifestSignature.clear(); + m_manifestSignatureAlg.clear(); + m_manifestKeyId.clear(); + m_manifestSigned = false; + + if (code == 200) + { + const QJsonObject envelope = resp.value(QStringLiteral("data")).isObject() + ? resp.value(QStringLiteral("data")).toObject() + : resp; + QString manifestText = envelope.value(QStringLiteral("manifestText")).toString(); + if (manifestText.isEmpty()) + manifestText = envelope.value(QStringLiteral("manifest_text")).toString(); + const QJsonObject manifest = envelope.value(QStringLiteral("manifest")).toObject(); + if (!manifestText.isEmpty() && !manifest.isEmpty()) + { + m_manifestText = manifestText; + m_manifest = manifest; + m_manifestSha256 = envelope.value(QStringLiteral("manifestSha256")).toString( + envelope.value(QStringLiteral("manifest_sha256")).toString()); + m_manifestSignature = envelope.value(QStringLiteral("signature")).toString( + m_manifest.value(QStringLiteral("signature")).toString()); + m_manifestSignatureAlg = envelope.value(QStringLiteral("signatureAlg")).toString( + envelope.value(QStringLiteral("signature_alg")).toString()); + m_manifestKeyId = envelope.value(QStringLiteral("keyId")).toString( + envelope.value(QStringLiteral("key_id")).toString()); + m_manifestSigned = envelope.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty()); + qDebug() << "Received manifest version:" << m_manifest.value("version").toString(); + m_fileItems.clear(); + QJsonArray files = m_manifest.value("files").toArray(); for (const QJsonValue& fileItem : files) { QJsonObject fileObj = fileItem.toObject(); - FileDownloadItem fi; - fi.path = fileObj.value("path").toString(); - fi.sha256 = fileObj.value("sha256").toString(); - fi.size = fileObj.value("size").toVariant().toLongLong(); - fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL - m_fileItems.append(fi); - } - } + FileDownloadItem fi; + fi.path = fileObj.value(QStringLiteral("path")).toString(); + fi.sha256 = fileObj.value(QStringLiteral("sha256")).toString(); + fi.size = manifestFileSize(fileObj); + fi.url = absoluteDownloadUrl(m_serverAddr, + fileObj.value(QStringLiteral("downloadUrl")).toString()); + m_fileItems.append(fi); + } + } else { qDebug() << "Manifest response missing fields"; m_error = QCoreApplication::translate("UpdaterLogic", - "Target version manifest response is incomplete. Stage: download target manifest. App: %1, channel: %2, version: %3, version id: %4.") - .arg(appId, channel, targetVer, QString::number(versionId)); + "Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4.") + .arg(appId, channel, targetVer, releaseId); } } else @@ -97,8 +192,8 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con qDebug() << "Failed to get manifest"; const QString detail = serverDetailMessage(resp); m_error = QCoreApplication::translate("UpdaterLogic", - "Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6") - .arg(QString::number(code), appId, channel, targetVer, QString::number(versionId), + "Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. Product: %2, channel: %3, version: %4, release id: %5.%6") + .arg(QString::number(code), appId, channel, targetVer, releaseId, detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail)); } emit fetchUrlFinished(); @@ -197,13 +292,30 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const "No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully."); return false; } - QString signature = m_manifest.value("signature").toString(); - if (signature.isEmpty()) + if (!m_manifestSha256.isEmpty()) { + const QString actualSha = QString::fromLatin1( + QCryptographicHash::hash(m_manifestText.toUtf8(), QCryptographicHash::Sha256).toHex()); + if (actualSha.compare(m_manifestSha256, Qt::CaseInsensitive) != 0) { + m_error = QCoreApplication::translate("UpdaterLogic", + "Manifest SHA-256 does not match the server envelope. Stage: manifest digest verification.\nExpected SHA-256: %1\nActual SHA-256: %2") + .arg(m_manifestSha256, actualSha); + return false; + } + } + + const bool requireSignature = configFlag(QStringLiteral("require_manifest_signature")); + const QString signature = m_manifestSignature.trimmed(); + if (signature.isEmpty() || !m_manifestSigned) { - qDebug() << "Manifest signature empty"; - m_error = QCoreApplication::translate("UpdaterLogic", - "The manifest does not contain a signature. Stage: manifest signature verification."); - return false; + if (requireSignature) { + qDebug() << "Manifest signature empty"; + m_error = QCoreApplication::translate("UpdaterLogic", + "The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification."); + return false; + } + qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled."; + m_error.clear(); + return true; } QString path = publicKeyPath; @@ -242,9 +354,15 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const return false; } - QJsonObject wrapper; - wrapper["manifest"] = m_manifest; - wrapper["manifest_text"] = m_manifestText; + QJsonObject wrapper; + wrapper["manifest"] = m_manifest; + wrapper["manifestText"] = m_manifestText; + wrapper["manifest_text"] = m_manifestText; + wrapper["manifestSha256"] = m_manifestSha256; + wrapper["signature"] = m_manifestSignature; + wrapper["signatureAlg"] = m_manifestSignatureAlg; + wrapper["keyId"] = m_manifestKeyId; + wrapper["signed"] = m_manifestSigned; QJsonDocument doc(wrapper); file.write(doc.toJson(QJsonDocument::Indented)); @@ -277,15 +395,27 @@ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& ver } QJsonObject wrapper = doc.object(); - if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) { + if (!wrapper.contains("manifest") + || (!wrapper.contains("manifestText") && !wrapper.contains("manifest_text"))) { m_error = QCoreApplication::translate("UpdaterLogic", "Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.") .arg(version, filePath); return false; } - + m_manifest = wrapper["manifest"].toObject(); - m_manifestText = wrapper["manifest_text"].toString(); + m_manifestText = wrapper.value(QStringLiteral("manifestText")).toString(); + if (m_manifestText.isEmpty()) + m_manifestText = wrapper.value(QStringLiteral("manifest_text")).toString(); + m_manifestSha256 = wrapper.value(QStringLiteral("manifestSha256")).toString( + wrapper.value(QStringLiteral("manifest_sha256")).toString()); + m_manifestSignature = wrapper.value(QStringLiteral("signature")).toString( + m_manifest.value(QStringLiteral("signature")).toString()); + m_manifestSignatureAlg = wrapper.value(QStringLiteral("signatureAlg")).toString( + wrapper.value(QStringLiteral("signature_alg")).toString()); + m_manifestKeyId = wrapper.value(QStringLiteral("keyId")).toString( + wrapper.value(QStringLiteral("key_id")).toString()); + m_manifestSigned = wrapper.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty()); qDebug() << "Loaded cached manifest" << version; m_error.clear(); return true; @@ -339,40 +469,19 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded()); } - QSet protectedPaths{ - QStringLiteral("bootstrap"), - QStringLiteral("bootstrap.exe"), - QStringLiteral("launcher"), - QStringLiteral("launcher.exe"), - QStringLiteral("updater"), - QStringLiteral("updater.exe"), - QStringLiteral("client.ini"), - QStringLiteral("config/app_config.json"), - QStringLiteral("config/local_state.json"), - QStringLiteral("config/client_identity.dat"), - QStringLiteral("config/version_policy.dat") - }; - const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); - if (!runtimePrefix.isEmpty()) { - const QStringList runtimeProtected{ - QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"), - QStringLiteral("launcher"), QStringLiteral("launcher.exe"), - QStringLiteral("updater"), QStringLiteral("updater.exe"), - QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"), - QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"), - QStringLiteral("config/version_policy.dat") - }; - for (const QString& path : runtimeProtected) - protectedPaths.insert(runtimePrefix + "/" + path); - } - QStringList obsolete; - QSet seen; - for (const QJsonValue& value : oldManifest.value("files").toArray()) { - const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); - const QString folded = path.toCaseFolded(); - if (!isSafeRelativePath(path) || protectedPaths.contains(folded) - || newPaths.contains(folded) || seen.contains(folded)) - continue; + QStringList obsolete; + QSet seen; + for (const QJsonValue& value : oldManifest.value("files").toArray()) { + const QJsonObject item = value.toObject(); + if (item.contains(QStringLiteral("required")) + && !item.value(QStringLiteral("required")).toBool(true)) { + continue; + } + const QString path = QDir::fromNativeSeparators(item.value("path").toString()); + const QString folded = path.toCaseFolded(); + if (!isSafeRelativePath(path) || isRuntimeProtectedPath(path) + || newPaths.contains(folded) || seen.contains(folded)) + continue; seen.insert(folded); obsolete.append(path); } @@ -423,6 +532,15 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& .arg(stage, version, path, fullPath); return false; } + const qint64 expectedSize = manifestFileSize(fileObject); + if (expectedSize >= 0 && QFileInfo(fullPath).size() != expectedSize) + { + m_error = QCoreApplication::translate("UpdaterLogic", + "File size does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected size: %5 bytes\nActual size: %6 bytes") + .arg(stage, version, path, fullPath, + QString::number(expectedSize), QString::number(QFileInfo(fullPath).size())); + return false; + } const QString actualSha = calcLocalFileSha256(fullPath); if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) { @@ -462,17 +580,25 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The manifest digest does not match the package signature"); return false; } QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object(); if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; } - manifest.insert("signature", wrapper.value("manifest_signature").toString()); - m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); - if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; } + manifest.insert("signature", wrapper.value("manifest_signature").toString()); + m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); + m_manifestSha256 = packageMeta.value("manifest_sha256").toString(); + m_manifestSignature = wrapper.value("manifest_signature").toString(); + m_manifestSignatureAlg = wrapper.value("signature_alg").toString("RSA-SHA256"); + m_manifestKeyId = wrapper.value("key_id").toString(); + m_manifestSigned = !m_manifestSignature.isEmpty(); + const QString manifestProduct = manifest.value("productCode").toString(manifest.value("app_id").toString()); + const QString packageProduct = packageMeta.value("productCode").toString(packageMeta.value("app_id").toString()); + if (manifestProduct != packageProduct || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; } if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid"); return false; } const qint64 payloadStart = 16 + qint64(headerSize); const QJsonArray entries = packageMeta.value("files").toArray(); for (const QJsonValue& value : entries) { - const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); - const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); + const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); + const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-range data: %1").arg(path); return false; } FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi); + if (isRuntimeProtectedPath(path)) continue; if (stagingDir.isEmpty()) continue; const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare offline file: %1").arg(path); return false; } QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create staged file: %1").arg(path); return false; } @@ -486,48 +612,18 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) { + Q_UNUSED(appId); + Q_UNUSED(channel); + Q_UNUSED(targetVer); + Q_UNUSED(versionId); m_error.clear(); - QString url = m_serverAddr + "/api/v1/update/download-url"; - QJsonObject body; - body["app_id"] = appId; - body["channel"] = channel; - body["version"] = targetVer; - body["version_id"] = versionId; - - QJsonArray emptyFiles; - body["files"] = emptyFiles; - - m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp) - { - qDebug() << "Download URL API returned code:" << code; - m_fileItems.clear(); - - if (code == 200) - { - QJsonArray fileArr = resp["files"].toArray(); - for (auto item : fileArr) - { - QJsonObject obj = item.toObject(); - FileDownloadItem fi; - fi.path = obj["path"].toString(); - fi.url = obj["url"].toString(); - fi.sha256 = obj["sha256"].toString(); - fi.size = obj["size"].toVariant().toLongLong(); - m_fileItems.append(fi); - qDebug() << "File info:" << fi.path << fi.url << fi.sha256; - } - } - else - { - qDebug() << "Failed to get download URL"; - const QString detail = serverDetailMessage(resp); - m_error = QCoreApplication::translate("UpdaterLogic", - "Cannot get secure download URLs. Stage: request download URLs. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6") - .arg(QString::number(code), appId, channel, targetVer, QString::number(versionId), - detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail)); - } - emit fetchUrlFinished(); - }); + if (m_fileItems.isEmpty()) { + m_error = QCoreApplication::translate("UpdaterLogic", + "The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads."); + } else { + qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest."; + } + emit fetchUrlFinished(); } @@ -607,12 +703,15 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat return false; } - QNetworkAccessManager manager; - manager.setProxy(QNetworkProxy::NoProxy); - QNetworkRequest request(url); - request.setTransferTimeout(60000); - if (existingSize > 0) - request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-"); + QNetworkAccessManager manager; + manager.setProxy(QNetworkProxy::NoProxy); + QNetworkRequest request{QUrl(url)}; + request.setTransferTimeout(60000); + const QString clientToken = configValue(QStringLiteral("client_token")); + if (!clientToken.isEmpty()) + request.setRawHeader("X-Client-Token", clientToken.toUtf8()); + if (existingSize > 0) + request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-"); QNetworkReply* reply = manager.get(request); QEventLoop loop; @@ -636,12 +735,12 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat partFile.flush(); partFile.close(); - const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - const bool networkOk = reply->error() == QNetworkReply::NoError; - const QString networkError = reply->errorString(); - reply->deleteLater(); - - if (existingSize > 0 && httpStatus == 200) + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool networkOk = reply->error() == QNetworkReply::NoError; + const QString networkError = reply->errorString(); + reply->deleteLater(); + + if (existingSize > 0 && httpStatus == 200) { // The server ignored Range; the current file is "old fragment + full response" and must be redownloaded safely. qDebug() << "Server ignored Range; restart full download:" << savePath; @@ -710,41 +809,16 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat return false; } -bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const -{ - const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); - QSet protectedPaths{ - QStringLiteral("bootstrap"), - QStringLiteral("bootstrap.exe"), - QStringLiteral("client.ini"), - QStringLiteral("config/app_config.json"), - QStringLiteral("config/local_state.json"), - QStringLiteral("config/client_identity.dat"), - QStringLiteral("config/version_policy.dat") - }; - const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); - if (!runtimePrefix.isEmpty()) { - const QStringList runtimeProtected{ - QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"), - QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"), - QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat") - }; - for (const QString& protectedPath : runtimeProtected) - protectedPaths.insert(runtimePrefix + "/" + protectedPath); - } - return protectedPaths.contains(normalized); -} - -bool UpdaterLogic::isSafeRelativePath(const QString& path) const -{ - const QString normalized = QDir::fromNativeSeparators(path); - const QString clean = QDir::cleanPath(normalized); - return !clean.isEmpty() - && !QDir::isAbsolutePath(clean) - && clean != ".." - && !clean.startsWith("../") - && !clean.contains(":"); -} +bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const +{ + return UpdatePathPolicy::isFullUpdateProtectedPath( + path, ConfigHelper::instance().runtimeRelativePath()); +} + +bool UpdaterLogic::isSafeRelativePath(const QString& path) const +{ + return UpdatePathPolicy::isSafeRelativePath(path); +} qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const @@ -879,42 +953,27 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe return true; } -void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, - const QString& version, bool success) -{ - QJsonArray files; - for (const FileDownloadItem& item : m_fileItems) - files.append(QJsonObject{{"path", item.path}, {"size", item.size}}); - QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version}, - {"result", success ? "success" : "fail"}, {"files", files}}; - m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body, - [](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; }); -} - -void UpdaterLogic::reportResult(const QString& deviceId, - const QString& fromVer, - const QString& toVer, - bool success) -{ - QString url = m_serverAddr + "/api/v1/update/report"; - - QJsonObject body; - body["app_id"] = ConfigHelper::instance().getValue("App", "app_id"); - body["device_id"] = deviceId; - body["from_version"] = fromVer; - body["to_version"] = toVer; - - if (success) - body["result"] = "success"; - else - body["result"] = "fail"; - - m_http.postRequest(url, body, [](int code, const QJsonObject& resp) - { - Q_UNUSED(resp); - qDebug() << "Update result report returned code:" << code; - }); -} +void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, + const QString& version, bool success) +{ + Q_UNUSED(appId); + Q_UNUSED(channel); + Q_UNUSED(version); + Q_UNUSED(success); + qDebug() << "Download result report is not part of the current SimCAE Hub API; skipped."; +} + +void UpdaterLogic::reportResult(const QString& deviceId, + const QString& fromVer, + const QString& toVer, + bool success) +{ + Q_UNUSED(deviceId); + Q_UNUSED(fromVer); + Q_UNUSED(toVer); + Q_UNUSED(success); + qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped."; +} QList UpdaterLogic::getFileList() const { diff --git a/Updater/UpdaterLogic.h b/Updater/UpdaterLogic.h index 87dc43c..6ac7bd9 100644 --- a/Updater/UpdaterLogic.h +++ b/Updater/UpdaterLogic.h @@ -24,7 +24,8 @@ class UpdaterLogic : public QObject public: explicit UpdaterLogic(QObject* parent = nullptr); - void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId); + void getManifest(const QString& appId, const QString& channel, const QString& targetVer, + int versionId, const QString& releaseId = QString()); bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const; bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; bool saveManifestCache(const QString& cacheDir) const; @@ -61,8 +62,13 @@ private: HttpHelper m_http; QString m_serverAddr; - QJsonObject m_manifest; - QString m_manifestText; + QJsonObject m_manifest; + QString m_manifestText; + QString m_manifestSha256; + QString m_manifestSignature; + QString m_manifestSignatureAlg; + QString m_manifestKeyId; + bool m_manifestSigned = false; QList m_fileItems; bool m_downloadAllOk = false; qint64 m_downloadTotalBytes = 0; diff --git a/Updater/main.cpp b/Updater/main.cpp index 8ad86fc..72ec9af 100644 --- a/Updater/main.cpp +++ b/Updater/main.cpp @@ -24,7 +24,7 @@ int main(int argc, char* argv[]) { QApplication app(argc, argv); - QApplication::setApplicationName("Marsco Updater"); + QApplication::setApplicationName("SimCAE Updater"); QTranslator translator; if (translator.load(":/i18n/update-client_zh_CN.qm")) app.installTranslator(&translator); @@ -35,10 +35,11 @@ int main(int argc, char* argv[]) UpdaterLogic logic; QString offlinePackagePath; - QString appId; - QString channel; - QString targetVersion; - int targetVersionId = 0; + QString appId; + QString channel; + QString targetVersion; + QString releaseId; + int targetVersionId = 0; if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); if (!logic.loadOfflinePackage(offlinePackagePath)) { @@ -62,11 +63,13 @@ int main(int argc, char* argv[]) appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt(); } QString bootstrapResult; - for (int i = 5; i < argc; ++i) { - const QString arg = argv[i]; - if (arg.startsWith("--bootstrap-resume=")) - bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); - } + for (int i = 5; i < argc; ++i) { + const QString arg = argv[i]; + if (arg.startsWith("--bootstrap-resume=")) + bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); + else if (arg.startsWith("--release-id=")) + releaseId = arg.mid(QString("--release-id=").size()); + } const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); const QString runtimeDir = QApplication::applicationDirPath(); @@ -74,7 +77,13 @@ int main(int argc, char* argv[]) const QString targetDir = config.installRoot(); const QString updateDir = config.updateRoot(); QDir().mkpath(updateDir); - if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) { + QString configuredProductCode = config.getValue("App", "product_code").trimmed(); + if (configuredProductCode.isEmpty()) + configuredProductCode = config.getValue("App", "app_id").trimmed(); + QString configuredChannel = config.getValue("App", "channel").trimmed(); + if (configuredChannel.isEmpty()) + configuredChannel = QStringLiteral("stable"); + if (appId != configuredProductCode || channel != configuredChannel) { QMessageBox::critical(nullptr, QCoreApplication::translate("Updater", "Offline Package Not Applicable"), QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration.")); @@ -310,7 +319,7 @@ int main(int argc, char* argv[]) withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."), logic.errorString())); } else if (offlinePackagePath.isEmpty()) { - logic.getManifest(appId, channel, targetVersion, targetVersionId); + logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId); } if (!logic.verifyManifestSignature()) { if (resumingFromBootstrap) diff --git a/config/app_config.example.json b/config/app_config.example.json deleted file mode 100644 index a8f079e..0000000 --- a/config/app_config.example.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "app_id": "simcae", - "app_name": "SimCAE", - "channel": "stable", - "current_version": "1.0.0", - "client_protocol": "3", - "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", - "license_key": "", - "client_token": "SimCAEClientToken2026", - "request_timeout_ms": "5000", - "temp_folder": "update_temp", - "device_id": "", - "install_root": "..", - "main_executable": "SimCAE.exe", - "launcher_executable": "Launcher.exe", - "updater_executable": "Updater.exe", - "bootstrap_executable": "Bootstrap.exe", - "health_check_timeout_ms": "15000", - "platform": "windows", - "arch": "x64" -} diff --git a/config/app_config.linux.example.json b/config/app_config.linux.example.json deleted file mode 100644 index 6c43b2b..0000000 --- a/config/app_config.linux.example.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "app_id": "simcae", - "app_name": "SimCAE", - "channel": "stable", - "current_version": "1.0.0", - "client_protocol": "3", - "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", - "license_key": "", - "client_token": "SimCAEClientToken2026", - "request_timeout_ms": "5000", - "temp_folder": "update_temp", - "device_id": "", - "install_root": "..", - "main_executable": "SimCAE", - "launcher_executable": "Launcher", - "updater_executable": "Updater", - "bootstrap_executable": "Bootstrap", - "health_check_timeout_ms": "15000", - "platform": "linux", - "arch": "x64" -} diff --git a/config/server_config.example.json b/config/server_config.example.json deleted file mode 100644 index 6903099..0000000 --- a/config/server_config.example.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "api_base_url": "http://192.168.1.158:8000" -} \ No newline at end of file diff --git a/config/server_config.json b/config/server_config.json index 099e80f..dbdd7c6 100644 --- a/config/server_config.json +++ b/config/server_config.json @@ -1,3 +1,3 @@ { - "api_base_url": "http://192.168.229.128:8000" + "api_base_url": "http://192.168.1.158:18000" } diff --git a/i18n/update-client_zh_CN.qm b/i18n/update-client_zh_CN.qm deleted file mode 100644 index 82e6dd1..0000000 Binary files a/i18n/update-client_zh_CN.qm and /dev/null differ diff --git a/i18n/update-client_zh_CN.ts b/i18n/update-client_zh_CN.ts index 2084750..72bfe46 100644 --- a/i18n/update-client_zh_CN.ts +++ b/i18n/update-client_zh_CN.ts @@ -69,183 +69,84 @@ Click OK, then choose Yes in the Windows permission confirmation dialog.用户取消了管理员权限请求。 - - DeviceIdentityHelper - - - OpenSSL is unavailable, so device credential signature cannot be verified. - 当前程序未启用 OpenSSL,无法验证设备凭证签名。 - - - - Device public key is missing: %1 - 设备凭证公钥缺失:%1 - - - - Device public key is invalid: %1 - 设备凭证公钥无效:%1 - - - - Device credential signature is invalid. The local identity file may not match this server. - 设备凭证签名无效。本机身份文件可能不是当前服务器签发的。 - - - - Device credential file is not valid JSON: %1 - 设备凭证文件不是合法 JSON:%1 - - - - Device credential does not match this application, channel, license, installation or device. - 设备凭证与当前应用、渠道、License、安装实例或设备不匹配。 - - - - License has expired. Please ask the administrator to issue a new License. - License 已过期。请联系管理员重新签发 License。 - - - - app_id is empty in app_config.json. - app_config.json 中的 app_id 为空。 - - - - channel is empty in app_config.json. - app_config.json 中的 channel 为空。 - - - - Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000 - 服务端地址未配置。请先设置 config/server_config.json,再重新编译 Launcher。例如:http://192.168.229.128:8000 - - - - client_token is empty. Copy the client_token generated by the admin page into app_config.json. - client_token 为空。请把管理后台生成的 client_token 复制到 app_config.json。 - - - - License is empty. Create or select a License in the admin page, then copy the generated client configuration. - License 为空。请先在管理后台创建或选择 License,然后复制生成的客户端配置。 - - - - Cannot save installation id to %1: %2 - 无法保存安装实例 ID 到 %1:%2 - - - - Cannot contact the update server to issue device identity. -Server: %1 -App: %2 -Channel: %3 -Network error: %4 -Timeout: %5 ms - 无法连接更新服务器来签发设备身份。 -服务器:%1 -App:%2 -渠道:%3 -网络错误:%4 -超时时间:%5 毫秒 - - - - Device identity request was rejected by the update server. -Server: %1 -HTTP status: %2 -App: %3 -Channel: %4 -Server message: %5 - 设备身份请求被更新服务器拒绝。 -服务器:%1 -HTTP 状态码:%2 -App:%3 -渠道:%4 -服务端消息:%5 - - - - <empty response> - <空响应> - - - - Server returned an invalid device identity response. - 服务端返回的设备身份响应格式无效。 - - - - Cannot save device credential to %1: %2 - 无法保存设备凭证到 %1:%2 - - - - Cannot save server device id to %1: %2 - 无法保存服务端设备 ID 到 %1:%2 - - IntegrityHelper - + Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification. 无法验证签名 Manifest,因为当前程序未启用 OpenSSL。阶段:已安装版本校验。 - + Cannot open manifest public key. Stage: installed version verification. Public key path: %1. 无法打开 Manifest 公钥。阶段:已安装版本校验。公钥路径:%1。 - + Manifest public key is invalid. Stage: installed version verification. Public key path: %1. Manifest 公钥无效。阶段:已安装版本校验。公钥路径:%1。 - + 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. Manifest RSA 签名无效。阶段:已安装版本校验。通常表示本地缓存的 Manifest 被改过、客户端公钥与服务端私钥不匹配,或正在使用错误版本的缓存。 - + 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. 本地签名 Manifest 缓存缺失。阶段:已安装版本校验。版本:%1。期望缓存文件:%2。该缓存会在同版本发布或安装成功后生成。 - + Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3. 本地签名 Manifest 缓存不是合法 JSON。阶段:已安装版本校验。版本:%1。文件:%2。JSON 错误:%3。 - + Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2. 本地签名 Manifest 缓存不完整。阶段:已安装版本校验。版本:%1。文件:%2。 - + + Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1. +Expected SHA-256: %2 +Actual SHA-256: %3 + + + + + Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1. + + + + Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3. 签名 Manifest 内容不是合法 JSON。阶段:已安装版本校验。版本:%1。文件:%2。JSON 错误:%3。 - - 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. - 本地签名 Manifest 身份与当前应用不匹配。阶段:已安装版本校验。期望 app/channel/version:%1 / %2 / %3。Manifest 中的 app/channel/version:%4 / %5 / %6。 + + Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6. + - + Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2. 签名 Manifest 包含不安全文件路径。阶段:已安装版本校验。版本:%1。路径:%2。 - + 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. 缺少必需的已安装文件。阶段:已安装版本校验。版本:%1。Manifest 路径:%2。检查路径:%3。本地安装目录已不再匹配发布版本。 - + + Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3. +Expected size: %4 bytes +Actual size: %5 bytes + + + + Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3. Expected SHA-256: %4 Actual SHA-256: %5 @@ -256,12 +157,12 @@ This means the installed file is different from the version that was published o 这表示当前安装目录里的文件和当初发布或安装成功的版本不同。如果这是开发测试机器,请检查发布后是否又重新编译或覆盖了本地 Release 目录。 - + <cannot read file> <无法读取文件> - + 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. 本地存在签名 Manifest 未声明的 EXE 或 DLL。阶段:已安装版本校验。版本:%1。额外文件:%2。请删除异常的可执行文件/插件,或发布一个声明这些文件的新版本。 @@ -269,157 +170,17 @@ This means the installed file is different from the version that was published o Launcher - + Checking for software updates... 正在检查软件更新... - - Marsco Launcher - Marsco 软件启动器 - - - - Please enter the License for %1: - 请输入 %1 的授权 License: - - - - - the application - 软件 - - - - %1 - -Please enter a new License for %2: - %1 - -请重新输入 %2 的授权 License: - - - - Enter License - 输入 License - - - - License Required - 需要 License - - - - A License provided by the administrator is required for the first launch. - 首次启动需要输入管理员提供的 License。 - - - - License Cannot Be Empty - License 不能为空 - - - - Please paste the License created in the admin page. - 请粘贴管理员在后台创建的 License。 - - - - Failed to Save License - 保存 License 失败 - - - - Cannot write the configuration file: -%1 -%2 - 无法写入配置文件: -%1 -%2 - - - - Verifying License... - 正在验证 License... - - - - Device Authentication Failed - 设备身份验证失败 - - - - The current License cannot be used: %1 - 当前 License 无法使用:%1 - - - - The device or License authorization is invalid. - 设备或 License 授权无效。 - - - - The current authorization was rejected by the server: %1 - 当前授权被服务端拒绝:%1 - - - - License Saved - License 已保存 - - - - Please restart Launcher to complete device authorization and update checking. - 请重新启动 Launcher 完成设备授权和更新检查。 - - - + Authorization Rejected 授权被拒绝 - - Select Offline Update Package - 选择离线更新包 - - - - Marsco offline update package (*.upd) - Marsco 离线更新包 (*.upd) - - - - Cannot import the offline update package because the updater executable does not exist. -Updater: %1 -Offline package: %2 - 无法导入离线更新包,因为 Updater 可执行文件不存在。 -Updater:%1 -离线包:%2 - - - - Cannot start the updater for offline package import. -Updater: %1 -Offline package: %2 -Check file permissions and dependent DLLs/shared libraries. - 无法启动 Updater 导入离线更新包。 -Updater:%1 -离线包:%2 -请检查文件权限和依赖 DLL/共享库。 - - - - - Offline Update - 离线更新 - - - - No offline update package was selected. - 未选择离线更新包。 - - - + Cannot start the main application because the executable file does not exist. Executable: %1 Check main_executable and install_root in the generated client configuration. @@ -428,7 +189,7 @@ Check main_executable and install_root in the generated client configuration. - + Cannot start the main application because the one-time launch ticket could not be created. Executable: %1 Details: %2 @@ -437,7 +198,7 @@ Details: %2 详细信息:%2 - + Cannot start the main application process. Executable: %1 Ticket file: %2 @@ -448,121 +209,108 @@ Ticket 文件:%2 请检查文件权限、依赖 DLL/共享库,以及该可执行文件是否能独立运行。 - - Verifying local runtime policy... - 正在验证本地运行策略... - - - - - Cannot Start - 无法启动 - - - - The local version policy is invalid: %1 - 本地版本策略无效:%1 - - - - The local version policy has expired. Please connect to the network or contact the administrator. - 本地版本策略已过期,请连接网络或联系管理员。 - - - - Current Version Cannot Run - 当前版本不可运行 - - - - The current version %1 has been disabled by the administrator. - 当前版本 %1 已被管理员停用。 - - - - Cannot read the local state: %1 - 无法读取本地状态:%1 - - - - - Security Check Failed - 安全检查失败 - - - - A version policy sequence rollback was detected. Startup has been blocked. - 检测到版本策略序列回退,已阻止启动。 - - - - A possible system time rollback was detected. Startup has been blocked. - 检测到系统时间可能被回拨,已阻止启动。 - - - - Generating Git tag list... - 正在生成 Git 标签清单... - - - - Git Tag List Failed - Git 标签清单生成失败 - - - - Cannot generate tags.txt, but startup will continue: -%1 - 无法生成 tags.txt,但启动会继续: -%1 - - - - Version Rollback - 版本回退 - - - - Update Required - 必须更新 - - - New Version Available 发现新版本 - - The administrator provided version %1 as the rollback target. Downgrade now? - 管理员提供了版本 %1 作为回退目标,是否现在降级? - - - + Version %1 is available. Update now? 发现新版本 %1,是否现在更新? - - -Target version: %1 - -目标版本:%1 + + SimCAE Launcher + - - + + + Customer Login + + + + + Enter the customer account email: + + + + + %1 + +Enter the customer account email: + + + + + Enter the customer account password: + + + + + Logging in... + + + + + Customer login failed: %1 + + + + + Customer Login Failed + + + + + + Refreshing customer session... + + + + + A customer account is required to check authorized updates and download release packages. + + + + + Checking authorized updates... + + + + + The stored customer session has expired. Please log in again. + + + + + The current customer account is not authorized to download this update. + + + + + Update Server Unavailable + + + + + Cannot connect to the update server. The installed application will be started without downloading an update. + + + + + Startup Failed 启动失败 - - + + Cannot start the main application: %1 无法启动主程序:%1 - + Cannot start the updater because the executable file does not exist. Executable: %1 Check updater_executable and install_root in the generated client configuration. @@ -571,7 +319,7 @@ Check updater_executable and install_root in the generated client configuration. 请检查生成的客户端配置中的 updater_executable 和 install_root。 - + Cannot start the updater process. Executable: %1 Arguments: %2 @@ -582,63 +330,21 @@ Check file permissions, dependent DLLs/shared libraries, and whether the updater 请检查文件权限、依赖 DLL/共享库,以及 Updater 是否能独立运行。 - - - - + + Offline startup. Starting... + + + + + Updater Startup Failed 更新器启动失败 - - Caching signed version manifest... - 正在缓存签名版本清单... - - - - Manifest Cache Failed - 清单缓存失败 - - - - Cannot cache the signed manifest for the current version: %1 - 无法缓存当前版本的签名清单:%1 - - - - Server Unavailable - 服务器不可用 - - - - Cannot connect to the update server. Import an offline update package? - 当前无法连接更新服务器。是否导入离线更新包? - - - - No offline update package was selected, or the updater could not be started. - 未选择离线更新包或无法启动更新器。 - - - - Network Unavailable - 网络不可用 - - - - Cannot connect to the update server, and the current policy does not allow offline startup. - 无法连接更新服务器,且当前策略不允许离线启动。 - - - + The application is up to date. Starting... 当前已是最新版本,正在启动... - - - Offline mode is active. Starting... - 当前处于离线模式,正在启动... - LocalStateHelper @@ -787,150 +493,135 @@ Check file permissions, dependent DLLs/shared libraries, and whether the updater UpdateLogic - - Current version manifest identity is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4. - 当前版本 Manifest 身份信息不完整。阶段:缓存当前版本 Manifest。App:%1,渠道:%2,版本:%3,版本 ID:%4。 + + Update configuration is incomplete. Server, product_code and current_version are required. + - - 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 - 无法下载当前本地版本的签名 Manifest。阶段:缓存当前版本 Manifest。HTTP 状态码:%1。App:%2,渠道:%3,版本:%4,版本 ID:%5。%6 + + Current version manifest identity is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3. + - + + Cannot download manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. Product: %2, channel: %3, version: %4.%5 + + + + Server message: %1 服务端消息:%1 - - 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. - 当前本地版本的签名 Manifest 响应不完整。阶段:缓存当前版本 Manifest。App:%1,渠道:%2,版本:%3,版本 ID:%4。 + + The manifest response for the current local version is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3. + - - 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. - 签名 Manifest 身份与当前本地版本不匹配。阶段:缓存当前版本 Manifest。期望 app/channel/version:%1 / %2 / %3。Manifest 中的 app/channel/version:%4 / %5 / %6。 + + The manifest identity does not match the current local version. Stage: cache current version manifest. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6. + - + + Cannot save manifest cache. Stage: cache current version manifest. File: %1. Error: %2. + + + + Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1. 无法创建 Manifest 缓存目录。阶段:缓存当前版本 Manifest。目录:%1。 - - - Cannot save signed manifest cache. Stage: cache current version manifest. File: %1. Error: %2. - 无法保存签名 Manifest 缓存。阶段:缓存当前版本 Manifest。文件:%1。错误:%2。 - - - - Server address is empty. - 服务端地址为空。 - - - - Git tags request failed (HTTP %1). - Git 标签请求失败(HTTP %1)。 - - - - Git tags response is empty. - Git 标签响应为空。 - - - - Cannot write Git tags file: %1 - 无法写入 Git 标签文件:%1 - Updater - + Invalid Offline Package 离线包无效 - + Updater Argument Error 更新器参数错误 - + The updater is missing online update arguments or an offline update package. 更新器缺少在线更新参数或离线更新包。 - + Offline Package Not Applicable 离线包不适用 - + The update package application or channel does not match the local configuration. 更新包的应用或渠道与本机配置不一致。 - + Offline Update Rejected 离线更新被拒绝 - + The local signed policy has expired, forbids offline updates, or does not allow the target version. 本地签名策略已过期、禁止离线更新或不允许目标版本。 - + Downgrade Forbidden 禁止降级 - + The current signed policy does not allow installing an offline package with a lower version. 当前签名策略不允许安装较低版本的离线包。 - - + + Update Recovery Failed 更新恢复失败 - + An unfinished update was detected, but the old version could not be restored: %1 Do not continue running the software. Please contact the administrator. 检测到上次更新未完成,但无法恢复旧版本:%1 请不要继续运行软件,并联系管理员。 - + The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory. 旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。 - + Preparing update... 正在准备更新... - + Updating to %1 正在更新到 %1 - + Preparing download... 正在准备下载... - + Downloading: %1 正在下载:%1 - + Details: @@ -941,7 +632,7 @@ Details: %1 - + Cannot start the main application because the executable file does not exist. Executable: %1 Check main_executable and install_root in the generated client configuration. @@ -950,7 +641,7 @@ Check main_executable and install_root in the generated client configuration. - + Cannot start the main application because the one-time launch ticket could not be created. Executable: %1 Details: %2 @@ -959,7 +650,7 @@ Details: %2 详细信息:%2 - + Cannot start the main application process. Executable: %1 Ticket file: %2 @@ -972,17 +663,17 @@ Ticket 文件:%2 请检查文件权限、依赖 DLL/共享库,以及该可执行文件是否能独立运行。 - + <not used> <未使用> - + Delegating rollback to Bootstrap... 正在将回滚工作移交给 Bootstrap... - + Cannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator. @@ -991,356 +682,356 @@ Cannot start Bootstrap to perform rollback. Do not continue running the software 无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。 - + Transaction Resume Failed 事务续办失败 - + Cannot read the Bootstrap update transaction: %1 无法读取 Bootstrap 更新事务:%1 - + Update Rolled Back 更新已回滚 - + The new version failed to install or start. The old version has been restored and started automatically. 新版本安装或启动失败,已自动恢复并启动旧版本。 - + The old files were restored, but the old version number could not be written back. Please check configuration directory permissions. 旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。 - + Automatic Rollback Failed 自动回滚失败 - + Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator. Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。 - - + + Update Preparation Failed 更新准备失败 - + Cannot create the update transaction directory or save the transaction state. 无法创建更新事务目录或保存事务状态。 - + Cannot save the offline transaction marker. 无法保存离线事务标记。 - + Fetching and verifying version manifest... 正在获取并验证版本清单... - + Verifying offline update package... 正在验证离线更新包... - - - + + + Manifest Cache Failed 清单缓存失败 - + Cannot read the signed manifest cache after Bootstrap installation. Bootstrap 安装后无法读取签名 Manifest 缓存。 - - + + Security Verification Failed 安全验证失败 - + Cannot reverify the manifest signature after Bootstrap installation. Bootstrap 安装后无法重新验证版本清单签名。 - + The version manifest signature is invalid. The update has stopped. Please contact the administrator. 版本清单签名无效,更新已停止。请联系管理员。 - + Cannot save the new version manifest cache. 无法保存新版本 Manifest 缓存。 - + Cannot save the new version manifest cache. The update has stopped. 无法保存新版本 Manifest 缓存,更新已停止。 - + Fetching secure download URLs... 正在获取安全下载地址... - + Preparing offline package files... 正在准备离线包文件... - + No Files to Update 没有可更新文件 - + The server did not return any version files. The update has stopped. 服务器没有返回任何版本文件,更新已停止。 - + Insufficient Disk Space 磁盘空间不足 - + The update requires at least %1 of free space, but the installation drive currently has only %2. The required space includes downloaded files, old version backups, and a safety margin. 更新至少需要 %1 可用空间,安装盘当前仅剩 %2。 所需空间已包含下载文件、旧版本备份和安全余量。 - + Downloading and verifying %1 version files... 正在下载并校验 %1 个版本文件... - + Extracting and verifying %1 offline files... 正在提取并校验 %1 个离线文件... - + Offline Package Extraction Failed 离线包提取失败 - + Download Failed 下载失败 - + Some files failed to download or failed SHA-256 verification. Please check the network, update cache, or server release files and try again. 部分文件下载失败或 SHA-256 校验失败。请检查网络、更新缓存或服务端发布文件后重试。 - + The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files. 已下载/暂存的文件与目标版本 Manifest 不一致。更新已在替换已安装文件前停止。 - + New version files failed verification after installation. The updater will roll back to the previous version. 新版本文件在安装后校验失败。更新器将回滚到上一版本。 - + Verifying complete version files... 正在校验完整版本文件... - + File Verification Failed 文件校验失败 - + Bootstrap Cannot Self-Update Bootstrap 无法自更新 - + This version contains a new %1. Please upgrade this component with the installer, then publish the business version again. 本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。 - + Transaction Recording Failed 事务记录失败 - + Cannot save the list of verified or pending deletion files. The update has stopped. 无法保存已校验或待删除文件列表,更新已停止。 - + Closing the main application... 正在关闭主程序... - + Cannot Close Main Application 无法关闭主程序 - + %1 is still running. Please close it manually and try again. %1 仍在运行,请手动关闭后重试。 - + Backing up %1 files to be changed, including %2 files to be deleted... 正在备份 %1 个待变更文件(其中删除 %2 个)... - + Backup Failed 备份失败 - + Cannot back up current version files. The new version has not been installed. Please check disk space and directory permissions. 无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。 - - - - + + + + Bootstrap Handoff Preparation Failed 接管准备失败 - + Cannot create the Bootstrap file plan. 无法创建 Bootstrap 文件计划。 - + Cannot write the Bootstrap copy plan. 无法写入 Bootstrap 复制计划。 - + Cannot write the Bootstrap deletion plan. 无法写入 Bootstrap 删除计划。 - + Cannot commit the Bootstrap file plan or transaction state. 无法提交 Bootstrap 文件计划或事务状态。 - + Delegating installation to Bootstrap... 正在将安装工作移交给 Bootstrap... - + Bootstrap Startup Failed Bootstrap 启动失败 - + Cannot start the standalone update handoff program: %1 无法启动独立更新接管程序:%1 - + Verifying Bootstrap installation result... 正在校验 Bootstrap 安装结果... - + Installation Verification Failed 安装校验失败 - + Obsolete File Cleanup Failed 废弃文件清理失败 - + An obsolete file still exists: %1 废弃文件仍然存在:%1 - + Saving new version state... 正在保存新版本状态... - + State Save Failed 状态保存失败 - + Cannot save the current version number. 无法保存当前版本号。 - + Starting the new version and waiting for health confirmation... 正在启动新版本并等待健康确认... - + Startup Failed 启动失败 - + %1 cannot be started. %1 无法启动。 - + Startup Confirmation Failed 启动确认失败 - + The new version did not complete startup health confirmation within %1 milliseconds. 新版本在 %1 毫秒内没有完成启动健康确认。 - + Committing update transaction... 正在提交更新事务... - + Transaction Commit Failed 事务提交失败 - + The new version has started, but the update transaction could not be committed. 新版本已经启动,但无法提交更新事务。 - + Update Complete 更新完成 - + The software has been successfully updated to %1 and passed the startup health check. 软件已成功更新到 %1,并通过启动健康检查。 @@ -1348,120 +1039,133 @@ The required space includes downloaded files, old version backups, and a safety UpdaterLogic - - Target version manifest response is incomplete. Stage: download target manifest. App: %1, channel: %2, version: %3, version id: %4. - 目标版本 Manifest 响应不完整。阶段:下载目标版本 Manifest。App:%1,渠道:%2,版本:%3,版本 ID:%4。 - - - - Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6 - 无法下载目标版本 Manifest。阶段:下载目标版本 Manifest。HTTP 状态码:%1。App:%2,渠道:%3,版本:%4,版本 ID:%5。%6 - - - - + Server message: %1 服务端消息:%1 - + Cannot verify manifest signature because OpenSSL support is unavailable. Stage: manifest signature verification. 无法验证 Manifest 签名,因为当前程序未启用 OpenSSL。阶段:Manifest 签名校验。 - + Cannot open manifest public key. Stage: manifest signature verification. Public key path: %1. 无法打开 Manifest 公钥。阶段:Manifest 签名校验。公钥路径:%1。 - + Cannot parse manifest public key buffer. Stage: manifest signature verification. Public key path: %1. 无法解析 Manifest 公钥内容。阶段:Manifest 签名校验。公钥路径:%1。 - + Manifest public key is invalid. Stage: manifest signature verification. Public key path: %1. Manifest 公钥无效。阶段:Manifest 签名校验。公钥路径:%1。 - + Cannot create OpenSSL verification context. Stage: manifest signature verification. 无法创建 OpenSSL 验签上下文。阶段:Manifest 签名校验。 - + 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. Manifest RSA 签名无效。阶段:Manifest 签名校验。通常表示 Manifest 不是由匹配的服务端私钥签名、客户端公钥不正确,或 Manifest 内容被修改过。 - + No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully. 没有可用于验签的 Manifest。阶段:Manifest 签名校验。目标版本 Manifest 可能没有下载成功。 - - The manifest does not contain a signature. Stage: manifest signature verification. - Manifest 中没有签名字段。阶段:Manifest 签名校验。 + + Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4. + - + + Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. Product: %2, channel: %3, version: %4, release id: %5.%6 + + + + + Manifest SHA-256 does not match the server envelope. Stage: manifest digest verification. +Expected SHA-256: %1 +Actual SHA-256: %2 + + + + + The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification. + + + + Cannot save manifest cache because the target manifest is empty. Stage: save target manifest cache. 无法保存 Manifest 缓存,因为目标版本 Manifest 为空。阶段:保存目标版本 Manifest 缓存。 - + Cannot create manifest cache directory. Stage: save target manifest cache. Directory: %1. 无法创建 Manifest 缓存目录。阶段:保存目标版本 Manifest 缓存。目录:%1。 - + Cannot write manifest cache file. Stage: save target manifest cache. File: %1. Error: %2. 无法写入 Manifest 缓存文件。阶段:保存目标版本 Manifest 缓存。文件:%1。错误:%2。 - + Cannot read cached signed manifest. Stage: read local manifest cache. Version: %1. File: %2. This cache is created after a version is installed successfully. 无法读取本地签名 Manifest 缓存。阶段:读取本地 Manifest 缓存。版本:%1。文件:%2。该缓存会在版本安装成功后生成。 - + Cached signed manifest is not valid JSON. Stage: read local manifest cache. Version: %1. File: %2. 本地签名 Manifest 缓存不是合法 JSON。阶段:读取本地 Manifest 缓存。版本:%1。文件:%2。 - + Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2. 本地签名 Manifest 缓存不完整。阶段:读取本地 Manifest 缓存。版本:%1。文件:%2。 - + installed version verification 已安装版本校验 - + downloaded/staged file verification 已下载/暂存文件校验 - + No manifest is available. Stage: %1. The updater cannot know which files and hashes should be verified. 没有可用的 Manifest。阶段:%1。Updater 无法知道应该校验哪些文件和哈希。 - + Manifest contains an unsafe file path. Stage: %1. Version: %2. Path: %3. Manifest 包含不安全文件路径。阶段:%1。版本:%2。路径:%3。 - + 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. 缺少必需文件。阶段:%1。版本:%2。Manifest 路径:%3。检查路径:%4。如果这是下载更新阶段,说明文件没有正确下载或暂存;如果这是启动校验阶段,说明已安装文件可能被删除。 - + + File size does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4. +Expected size: %5 bytes +Actual size: %6 bytes + + + + File SHA-256 does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4. Expected SHA-256: %5 Actual SHA-256: %6 @@ -1472,114 +1176,114 @@ If this happens during download, clear the update cache and retry. If this happe 如果发生在下载阶段,请清理更新缓存后重试。如果发生在启动或安装后校验阶段,说明本地文件与发布版本不同。 - - + + <cannot read file> <无法读取文件> - + Cannot open the offline update package 无法打开离线更新包 - + The offline package format identifier is invalid 离线包格式标识无效 - + The offline package header is incomplete 离线包头不完整 - + The offline package header length is invalid 离线包头长度无效 - + The offline package header JSON is invalid 离线包头 JSON 无效 - + The offline package RSA signature is invalid 离线包 RSA 签名无效 - + The offline package signature metadata is invalid 离线包签名元数据无效 - + The manifest digest does not match the package signature Manifest 摘要与包签名不一致 - + The offline manifest is invalid 离线 Manifest 无效 - + Package information does not match manifest identity 包信息与 Manifest 身份不一致 - + The offline manifest RSA signature is invalid 离线 Manifest RSA 签名无效 - + The offline package contains an unsafe path or out-of-range data: %1 离线包包含不安全路径或越界数据:%1 - + Cannot prepare offline file: %1 无法准备离线文件:%1 - + Cannot create staged file: %1 无法创建暂存文件:%1 - + Failed to read offline file: %1 离线文件读取失败:%1 - + Offline file hash verification or write failed: %1 离线文件 Hash 或写入失败:%1 - + The offline package file count does not match the manifest 离线包文件数量与 Manifest 不一致 - - Cannot get secure download URLs. Stage: request download URLs. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6 - 无法获取安全下载链接。阶段:请求下载链接。HTTP 状态码:%1。App:%2,渠道:%3,版本:%4,版本 ID:%5。%6 + + The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads. + - + Cannot create partial download directory. Stage: download file. File: %1. Partial file: %2. 无法创建断点续传目录。阶段:下载文件。文件:%1。临时分片文件:%2。 - - + + Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3. 下载文件已通过 SHA-256 校验,但无法移动到暂存目录。阶段:下载文件。文件:%1。从:%2。到:%3。 - + Cached partial file has the expected size but wrong SHA-256. Stage: resume download. File: %1. Expected SHA-256: %2 Actual SHA-256: %3 @@ -1590,12 +1294,12 @@ The partial cache will be deleted and downloaded again. 该临时缓存会被删除并重新下载。 - + Cannot open partial download file. Stage: download file. File: %1. Partial file: %2. Error: %3. 无法打开断点续传临时文件。阶段:下载文件。文件:%1。临时分片文件:%2。错误:%3。 - + Downloaded file does not match the signed manifest. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes Actual size: %4 bytes @@ -1610,42 +1314,42 @@ The partial cache will be deleted and downloaded again. 该临时缓存会被删除并重新下载。 - + Downloaded file is incomplete. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes, current size: %4 bytes. 下载文件不完整。阶段:下载文件。文件:%1。HTTP 状态码:%2。期望大小:%3 字节,当前大小:%4 字节。 - + Download request failed. Stage: download file. File: %1. Attempt: %2/4. HTTP status: %3. Network error: %4. Partial file: %5. 下载请求失败。阶段:下载文件。文件:%1。尝试次数:%2/4。HTTP 状态码:%3。网络错误:%4。临时分片文件:%5。 - + File download failed after retries. Stage: download file. File: %1. 文件多次重试后仍下载失败。阶段:下载文件。文件:%1。 - + The target version manifest contains no downloadable files. Stage: prepare downloads. 目标版本 Manifest 中没有可下载文件。阶段:准备下载。 - + Cannot create update staging directory. Stage: prepare downloads. Directory: %1. 无法创建更新暂存目录。阶段:准备下载。目录:%1。 - + Cannot create download cache directory. Stage: prepare downloads. Directory: %1. 无法创建下载缓存目录。阶段:准备下载。目录:%1。 - + Manifest contains an unsafe file path. Stage: prepare file download. Path: %1. Manifest 包含不安全文件路径。阶段:准备文件下载。路径:%1。 - + Cannot create staging subdirectory. Stage: prepare file download. File: %1. Directory: %2. 无法创建暂存子目录。阶段:准备文件下载。文件:%1。目录:%2。 diff --git a/scripts/ReadMe.txt b/scripts/ReadMe.txt index 8b7367a..152845f 100644 --- a/scripts/ReadMe.txt +++ b/scripts/ReadMe.txt @@ -1,59 +1,33 @@ -客户端脚本说明 -============== +SimCAE Hub 客户端脚本说明 +========================== -本目录保存 update-client 的辅助脚本。项目根目录只保留源码、CMake 入口、Docs 和配置模板,脚本统一放在这里。 +本目录保存 Qt/C++ 客户端更新链路的辅助脚本。客户端仍然由 +Launcher、Updater、Bootstrap 和业务主程序组成,服务端接口使用当前 +SimCAE Hub 的 Go API。 脚本列表: 1. package-sdk.ps1 - 在 Windows 上生成给其他软件接入用的 UpdateClientSDK 包。 - 注意:SDK 包只面向运行接入,不包含 config/server_config.json 和 config/server_config.qrc。 - 服务端地址必须在打包前写入源码目录 config/server_config.json,并重新编译进 Launcher/Updater。 + 在 Windows 上生成给业务软件接入用的客户端更新运行时包。 2. package-client.ps1 - 在 Windows 上生成某个具体产品的最终客户端发布包。 + Windows 本地调试用的客户包脚本,需要手工提供 app_config.json。 + 新流程建议上传完整软件 ZIP 到 SimCAE Hub,由服务端生成最终配置。 3. install-sdk.ps1 - 将 SDK 运行时复制到业务软件 Release 目录。 + 把客户端更新运行时复制到业务软件 Release 目录。 4. package-sdk.sh - 在 Linux 上生成给其他软件接入用的 UpdateClientSDK 包,输出 tar.gz。 + 在 Linux 上生成客户端更新运行时包,输出 tar.gz。 5. package-client.sh - 在 Linux 上生成某个具体产品的最终客户端发布包,输出 tar.gz。 + Linux 本地调试用的客户包脚本,需要手工提供 app_config.json。 -推荐在 update-client 根目录执行: +SDK 打包命令、两种打包模式、参数含义和输出位置,统一看: -```powershell -.\scripts\package-sdk.ps1 ` - -SourceDir .\out\bin\Release ` - -OutputDir .\dist\UpdateClientSDK ` - -ZipFile .\dist\UpdateClientSDK.zip ` - -SdkVersion 0.1.0 -``` +../Docs/01-客户端接入打包部署指南.md -```powershell -.\scripts\package-client.ps1 ` - -SourceDir .\out\bin\Release ` - -ConfigFile .\config\app_config.json ` - -OutputDir .\dist\UpdateClient ` - -ZipFile .\dist\UpdateClient.zip -``` - -Linux 示例: - -```bash -bash ./scripts/package-sdk.sh \ - --source-dir ./out/linux/bin \ - --output-dir ./dist/UpdateClientSDK-linux \ - --archive ./dist/UpdateClientSDK-linux.tar.gz \ - --sdk-version 0.1.0 -``` - -```bash -bash ./scripts/package-client.sh \ - --source-dir /path/to/SimCAE \ - --config-file /path/to/SimCAE/bin/config/app_config.json \ - --output-dir ./dist/UpdateClient-linux \ - --archive ./dist/UpdateClient-linux.tar.gz -``` +生成 SDK 后,把 Launcher、Updater、Bootstrap 和必要运行库放进业务软件 +根目录或 bin 目录,再把完整软件目录压缩上传到 SimCAE Hub 管理后台的 +发布包页面。服务端会生成 config/app_config.json,并在启用 Manifest 签名 +时生成 config/manifest_public_key.pem。 diff --git a/scripts/install-sdk.ps1 b/scripts/install-sdk.ps1 index ad9bba6..670e790 100644 --- a/scripts/install-sdk.ps1 +++ b/scripts/install-sdk.ps1 @@ -1,28 +1,23 @@ param( - [Parameter(Mandatory=$true)] - [string]$SdkRoot, - - [string]$ReleaseDir = (Get-Location).Path, - - [switch]$OverwriteConfig, - - [switch]$IncludeQtRuntime -) + [Parameter(Mandatory=$true)] + [string]$SdkRoot, + + [string]$ReleaseDir = (Get-Location).Path, + + [switch]$IncludeQtRuntime +) $ErrorActionPreference = "Stop" $sdk = (Resolve-Path $SdkRoot).Path $release = (Resolve-Path $ReleaseDir).Path -$binDir = Join-Path $sdk "bin" -$configDir = Join-Path $sdk "config" -$appConfig = Join-Path $configDir "app_config.json" -$publicKey = Join-Path $configDir "manifest_public_key.pem" - -foreach ($path in @($binDir, $appConfig, $publicKey)) { - if (-not (Test-Path $path)) { - throw "SDK file is missing: $path" - } +$binDir = Join-Path $sdk "bin" + +foreach ($path in @($binDir)) { + if (-not (Test-Path $path)) { + throw "SDK file is missing: $path" + } } function Test-IsQtRuntimeItem { @@ -58,17 +53,8 @@ Get-ChildItem $binDir -Force | Where-Object { -not (Test-IsQtRuntimeItem $_) } | Copy-Item -Destination $release -Recurse -Force -$targetConfigDir = Join-Path $release "config" -New-Item $targetConfigDir -ItemType Directory -Force | Out-Null - -$targetAppConfig = Join-Path $targetConfigDir "app_config.json" -if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) { - Copy-Item $appConfig $targetAppConfig -Force -} else { - Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it." -} - -Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force - -Write-Host "SDK files installed to: $release" -Write-Host "Next: edit config/app_config.json, then start Launcher.exe." +$targetConfigDir = Join-Path $release "config" +New-Item $targetConfigDir -ItemType Directory -Force | Out-Null + +Write-Host "SDK files installed to: $release" +Write-Host "Next: package the whole application directory and upload it in SimCAE Hub. The server will generate config/app_config.json and config/manifest_public_key.pem when needed." diff --git a/scripts/package-client.ps1 b/scripts/package-client.ps1 index 4301163..e19cc6c 100644 --- a/scripts/package-client.ps1 +++ b/scripts/package-client.ps1 @@ -3,7 +3,8 @@ param( [Parameter(Mandatory = $true)] [string]$ConfigFile, [string]$OutputDir = "", - [string]$ZipFile = "" + [string]$ZipFile = "", + [switch]$SkipManifestCheck ) $ErrorActionPreference = "Stop" @@ -13,10 +14,10 @@ if ([string]::IsNullOrWhiteSpace($SourceDir)) { $SourceDir = Join-Path $RepoRoot "out/bin/Release" } if ([string]::IsNullOrWhiteSpace($OutputDir)) { - $OutputDir = Join-Path $RepoRoot "dist/UpdateClient" + $OutputDir = Join-Path $RepoRoot "dist/SimCAEUpdateClient" } if ([string]::IsNullOrWhiteSpace($ZipFile)) { - $ZipFile = Join-Path $RepoRoot "dist/UpdateClient.zip" + $ZipFile = Join-Path $RepoRoot "dist/SimCAEUpdateClient.zip" } $source = (Resolve-Path $SourceDir).Path @@ -64,12 +65,11 @@ function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestNam $localData = [Environment]::GetFolderPath("LocalApplicationData") if ([string]::IsNullOrWhiteSpace($localData)) { return "" } $installId = Get-InstallDirectoryId $RuntimeDir - return Join-Path $localData "Marsco\UpdateClientSDK\installations\$installId\update\manifest_cache\$ManifestName" + return Join-Path $localData "SimCAE\HubUpdateClient\installations\$installId\update\manifest_cache\$ManifestName" } - + $requiredFields = @( - "app_id", "channel", "current_version", - "client_token", "launch_token", "license_key", + "product_code", "channel", "current_version", "launch_token", "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" ) foreach ($field in $requiredFields) { @@ -127,9 +127,9 @@ $manifestCandidates = @( (Join-Path $source "update/manifest_cache/$manifestName") ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } $sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $sourceManifest -or -not (Test-Path $sourceManifest)) { +if (-not $SkipManifestCheck -and (-not $sourceManifest -or -not (Test-Path $sourceManifest))) { $searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine - throw "Missing signed Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging. Searched paths:$([Environment]::NewLine)$searched" + throw "Missing Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging, or pass -SkipManifestCheck for a first-time test package. Searched paths:$([Environment]::NewLine)$searched" } if (Test-Path $OutputDir) { @@ -155,14 +155,16 @@ $outputConfigDir = Split-Path $outputConfigPath -Parent New-Item $outputConfigDir -ItemType Directory -Force | Out-Null Copy-Item $config $outputConfigPath -Force -@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { +@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { $runtimeFile = Join-Path $outputConfigDir $_ if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } } -$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) -New-Item $manifestDir -ItemType Directory -Force | Out-Null -Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force +$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) +if ($sourceManifest -and (Test-Path $sourceManifest)) { + New-Item $manifestDir -ItemType Directory -Force | Out-Null + Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force +} $zipParent = Split-Path $ZipFile -Parent New-Item $zipParent -ItemType Directory -Force | Out-Null diff --git a/scripts/package-client.sh b/scripts/package-client.sh index 7ac03dc..3c16a60 100644 --- a/scripts/package-client.sh +++ b/scripts/package-client.sh @@ -6,8 +6,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SOURCE_DIR="$REPO_ROOT/out/linux/bin" CONFIG_FILE="" -OUTPUT_DIR="$REPO_ROOT/dist/UpdateClient-linux" -ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClient-linux.tar.gz" +OUTPUT_DIR="$REPO_ROOT/dist/SimCAEUpdateClient-linux" +ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEUpdateClient-linux.tar.gz" SKIP_MANIFEST_CHECK=0 usage() { @@ -17,8 +17,8 @@ Usage: package-client.sh --config-file FILE [options] Options: --source-dir DIR Release/install root to package. Default: ./out/linux/bin --config-file FILE app_config.json used by this client package. Required. - --output-dir DIR Output directory. Default: ./dist/UpdateClient-linux - --archive FILE Output tar.gz. Default: ./dist/UpdateClient-linux.tar.gz + --output-dir DIR Output directory. Default: ./dist/SimCAEUpdateClient-linux + --archive FILE Output tar.gz. Default: ./dist/SimCAEUpdateClient-linux.tar.gz --skip-manifest-check Skip current-version manifest cache check. -h, --help Show this help. EOF @@ -89,7 +89,7 @@ user_data_manifest_candidate() { local data_home="${XDG_DATA_HOME:-$HOME/.local/share}" local install_id install_id="$(install_directory_id "$runtime_dir")" - printf '%s/Marsco/UpdateClientSDK/installations/%s/update/manifest_cache/%s' \ + printf '%s/SimCAE/HubUpdateClient/installations/%s/update/manifest_cache/%s' \ "$data_home" "$install_id" "$manifest_name" } @@ -116,7 +116,7 @@ CONFIG_FILE="$(realpath "$CONFIG_FILE")" OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")" ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")" -for field in app_id channel current_version client_token launch_token license_key main_executable launcher_executable updater_executable bootstrap_executable; do +for field in product_code channel current_version launch_token main_executable launcher_executable updater_executable bootstrap_executable; do if [[ -z "$(json_value "$field")" ]]; then echo "Config file is missing required field: $field" >&2 exit 1 @@ -187,8 +187,8 @@ for candidate in "${MANIFEST_CANDIDATES[@]}"; do fi done if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then - echo "Missing signed Manifest cache for current version: $MANIFEST_NAME." >&2 - echo "Complete online update/verification for this version before packaging. Searched paths:" >&2 + echo "Missing Manifest cache for current version: $MANIFEST_NAME." >&2 + echo "Complete online update/verification for this version before packaging, or pass --skip-manifest-check for a first-time test package. Searched paths:" >&2 printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2 exit 1 fi diff --git a/scripts/package-sdk.ps1 b/scripts/package-sdk.ps1 index 899333f..b8dde53 100644 --- a/scripts/package-sdk.ps1 +++ b/scripts/package-sdk.ps1 @@ -3,7 +3,6 @@ param( [string]$OutputDir = "", [string]$ZipFile = "", [string]$SdkVersion = "0.1.0", - [string]$ExampleConfig = "", [switch]$IncludeDemoMainApp, [switch]$IncludeQtRuntime ) @@ -15,38 +14,24 @@ if ([string]::IsNullOrWhiteSpace($SourceDir)) { $SourceDir = Join-Path $RepoRoot "out/bin/Release" } if ([string]::IsNullOrWhiteSpace($OutputDir)) { - $OutputDir = Join-Path $RepoRoot "dist/UpdateClientSDK" + $OutputDir = Join-Path $RepoRoot "dist/SimCAEHubUpdateClientSDK" } if ([string]::IsNullOrWhiteSpace($ZipFile)) { - $ZipFile = Join-Path $RepoRoot "dist/UpdateClientSDK.zip" -} -if ([string]::IsNullOrWhiteSpace($ExampleConfig)) { - $ExampleConfig = Join-Path $RepoRoot "config/app_config.example.json" + $ZipFile = Join-Path $RepoRoot "dist/SimCAEHubUpdateClientSDK.zip" } $source = (Resolve-Path $SourceDir).Path -$exampleConfigPath = (Resolve-Path $ExampleConfig).Path - -$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") -foreach ($name in $requiredFiles) { + +$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") +foreach ($name in $requiredFiles) { $path = Join-Path $source $name if (-not (Test-Path $path)) { throw "SDK source directory is missing required file: $path" - } -} - -$publicKeyCandidates = @( - (Join-Path $source "config/manifest_public_key.pem"), - (Join-Path $source "manifest_public_key.pem"), - (Join-Path $RepoRoot "config/manifest_public_key.pem") -) -$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 -if (-not $publicKey) { - throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." -} - -$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { - $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or + } +} + +$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { + $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or $_.Extension -in @('.pdb', '.ilk') } if ($debugArtifacts) { @@ -85,9 +70,14 @@ function Test-IsQtRuntimeFile { } return ( - $Item.Name -match '^Qt5.*\.dll$' -or - $Item.Name -in @( - "libEGL.dll", + $Item.Name -match '^Qt5.*\.dll$' -or + $Item.Name -match '^vc_redist.*\.exe$' -or + $Item.Name -match '^vcredist.*\.exe$' -or + $Item.Name -match '^vcruntime.*\.dll$' -or + $Item.Name -match '^msvcp.*\.dll$' -or + $Item.Name -match '^concrt.*\.dll$' -or + $Item.Name -in @( + "libEGL.dll", "libGLESv2.dll", "opengl32sw.dll", "d3dcompiler_47.dll" @@ -95,19 +85,20 @@ function Test-IsQtRuntimeFile { ) } -Get-ChildItem $source -Force | Where-Object { - $_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_) -} | Copy-Item -Destination $binDir -Recurse -Force - -Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force -Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force +Get-ChildItem $source -Force | Where-Object { + $_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_) +} | Copy-Item -Destination $binDir -Recurse -Force $commonSourceDir = Join-Path $RepoRoot "Common" $commonSourceFiles = @( "ConfigHelper.h", "ConfigHelper.cpp", + "IntegrityHelper.h", + "IntegrityHelper.cpp", "TicketHelper.h", - "TicketHelper.cpp" + "TicketHelper.cpp", + "UpdatePathPolicy.h", + "UpdatePathPolicy.cpp" ) foreach ($commonFile in $commonSourceFiles) { $commonPath = Join-Path $commonSourceDir $commonFile @@ -144,6 +135,8 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in sdk_type = "external-updater-runtime" required_entry = "Launcher.exe" contains_demo_main_app = [bool]$IncludeDemoMainApp + contains_qt_runtime = [bool]$IncludeQtRuntime + contains_final_config = $false docs_entry = "Docs/01-客户端接入打包部署指南.md" word_guide_included = [bool]$wordGuideSource integration_sources = $commonSourceFiles diff --git a/scripts/package-sdk.sh b/scripts/package-sdk.sh index 1cc76e1..7fc54ef 100644 --- a/scripts/package-sdk.sh +++ b/scripts/package-sdk.sh @@ -5,11 +5,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SOURCE_DIR="$REPO_ROOT/out/linux/bin" -OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux" -ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz" +OUTPUT_DIR="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-linux" +ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-linux.tar.gz" SDK_VERSION="0.1.0" -EXAMPLE_CONFIG="$REPO_ROOT/config/app_config.linux.example.json" INCLUDE_DEMO_MAIN_APP=0 +INCLUDE_QT_RUNTIME=0 usage() { cat <<'EOF' @@ -17,11 +17,11 @@ Usage: package-sdk.sh [options] Options: --source-dir DIR Linux Release output directory. Default: ./out/linux/bin - --output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux - --archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz + --output-dir DIR SDK directory to generate. Default: ./dist/SimCAEHubUpdateClientSDK-linux + --archive FILE SDK tar.gz path. Default: ./dist/SimCAEHubUpdateClientSDK-linux.tar.gz --sdk-version VERSION SDK version. Default: 0.1.0 - --example-config FILE app_config template. Default: ./config/app_config.linux.example.json --include-demo-mainapp Include MainApp demo executable in SDK bin. + --include-qt-runtime Include Qt runtime files from the Release output directory. -h, --help Show this help. EOF } @@ -32,15 +32,14 @@ while [[ $# -gt 0 ]]; do --output-dir) OUTPUT_DIR="$2"; shift 2 ;; --archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;; --sdk-version) SDK_VERSION="$2"; shift 2 ;; - --example-config) EXAMPLE_CONFIG="$2"; shift 2 ;; --include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;; + --include-qt-runtime) INCLUDE_QT_RUNTIME=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; esac done SOURCE_DIR="$(realpath "$SOURCE_DIR")" -EXAMPLE_CONFIG="$(realpath "$EXAMPLE_CONFIG")" OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")" ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")" @@ -51,21 +50,6 @@ for name in Launcher Updater Bootstrap; do fi done -PUBLIC_KEY="" -for candidate in \ - "$SOURCE_DIR/config/manifest_public_key.pem" \ - "$SOURCE_DIR/manifest_public_key.pem" \ - "$REPO_ROOT/config/manifest_public_key.pem"; do - if [[ -f "$candidate" ]]; then - PUBLIC_KEY="$candidate" - break - fi -done -if [[ -z "$PUBLIC_KEY" ]]; then - echo "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." >&2 - exit 1 -fi - DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)" if [[ -n "$DEBUG_ARTIFACT" ]]; then echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2 @@ -76,6 +60,21 @@ fi rm -rf "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common" "$OUTPUT_DIR/Docs" +is_qt_runtime_item() { + local base="$1" + case "$base" in + bearer|iconengines|imageformats|platforms|styles|translations) + return 0 + ;; + libQt5*.so*|libEGL.so*|libGLESv2.so*|libqxcb.so*|libxcb*.so*|libstdc++.so*|libgcc_s.so*|libssl.so*|libcrypto.so*|opengl32sw.dll|d3dcompiler_47.dll) + return 0 + ;; + *) + return 1 + ;; + esac +} + shopt -s dotglob nullglob for item in "$SOURCE_DIR"/*; do base="$(basename "$item")" @@ -86,16 +85,16 @@ for item in "$SOURCE_DIR"/*; do fi ;; *) + if [[ "$INCLUDE_QT_RUNTIME" -eq 0 ]] && is_qt_runtime_item "$base"; then + continue + fi cp -a "$item" "$OUTPUT_DIR/bin/" ;; esac done shopt -u dotglob nullglob -cp "$EXAMPLE_CONFIG" "$OUTPUT_DIR/config/app_config.json" -cp "$PUBLIC_KEY" "$OUTPUT_DIR/config/manifest_public_key.pem" - -for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.cpp; do +for common_file in ConfigHelper.h ConfigHelper.cpp IntegrityHelper.h IntegrityHelper.cpp TicketHelper.h TicketHelper.cpp UpdatePathPolicy.h UpdatePathPolicy.cpp; do common_path="$REPO_ROOT/Common/$common_file" if [[ ! -f "$common_path" ]]; then echo "SDK Common integration source is missing: $common_path" >&2 @@ -134,13 +133,19 @@ cat > "$OUTPUT_DIR/sdk_manifest.json" <