Compare commits

...

5 Commits

37 changed files with 2802 additions and 3044 deletions
+1
View File
@@ -29,6 +29,7 @@ pdf_requirements.txt
*.obj *.obj
*.o *.o
*.pdb *.pdb
*.qm
*.ilk *.ilk
*.idb *.idb
*.tlog *.tlog
+7 -11
View File
@@ -113,21 +113,20 @@ add_subdirectory(MainApp)
# 默认启动项目 # 默认启动项目
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# Copy config templates and static resources to output bin folder. # Copy local-only static resources to output bin folder. Final customer
if(UNIX AND NOT APPLE AND EXISTS "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json") # app_config.json is generated by the Go server when a release package is
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json") # uploaded. Developers may create an untracked config/app_config.local.json for
else() # local debugging.
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
endif()
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config") set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem") 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}") set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移 # app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config") 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") 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("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) configure_file("${LOCAL_APP_CONFIG_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
endif() endif()
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat) 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}") 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) add_dependencies(Bootstrap update_client_translations)
# Install rules for packaging # 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}") if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config) install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin) install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
+2 -2
View File
@@ -13,10 +13,10 @@ set(SRC
LocalStateHelper.cpp LocalStateHelper.cpp
TicketHelper.h TicketHelper.h
TicketHelper.cpp TicketHelper.cpp
UpdatePathPolicy.h
UpdatePathPolicy.cpp
IntegrityHelper.h IntegrityHelper.h
IntegrityHelper.cpp IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
) )
add_library(Common STATIC ${SRC}) add_library(Common STATIC ${SRC})
+19 -14
View File
@@ -35,8 +35,8 @@ const QString kConfigKeyPrefix = QStringLiteral("--config-key-b64=");
const QString kConfigValuePrefix = QStringLiteral("--config-value-b64="); const QString kConfigValuePrefix = QStringLiteral("--config-value-b64=");
const QString kFilePathPrefix = QStringLiteral("--file-path-b64="); const QString kFilePathPrefix = QStringLiteral("--file-path-b64=");
const QString kFileDataPrefix = QStringLiteral("--file-data-b64="); const QString kFileDataPrefix = QStringLiteral("--file-data-b64=");
const QString kRegistryOrganization = QStringLiteral("Marsco"); const QString kRegistryOrganization = QStringLiteral("SimCAE");
const QString kRegistryApplication = QStringLiteral("UpdateClientSDK"); const QString kRegistryApplication = QStringLiteral("HubUpdateClient");
const QString kRegistryInstallationsGroup = QStringLiteral("installations"); const QString kRegistryInstallationsGroup = QStringLiteral("installations");
const QString kRegistryConfigGroup = QStringLiteral("config"); const QString kRegistryConfigGroup = QStringLiteral("config");
const QString kRegistryMetaGroup = QStringLiteral("_meta"); const QString kRegistryMetaGroup = QStringLiteral("_meta");
@@ -178,7 +178,8 @@ bool isRegistryManagedConfigKey(const QString& key)
{ {
// 服务端地址是编译期 qrc 配置,不进入注册表。 // 服务端地址是编译期 qrc 配置,不进入注册表。
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。 // 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
return key != kApiBaseUrlKey; Q_UNUSED(key);
return true;
} }
bool isPathInsideDirectory(const QString& path, const QString& directory) bool isPathInsideDirectory(const QString& path, const QString& directory)
@@ -478,7 +479,6 @@ ConfigHelper::ConfigHelper()
QCryptographicHash::Sha256).toHex()); QCryptographicHash::Sha256).toHex());
migrateLegacyIniIfNeeded(); migrateLegacyIniIfNeeded();
syncRegistryFromConfigFileIfChanged(); syncRegistryFromConfigFileIfChanged();
removeRegistryValue(kApiBaseUrlKey);
qDebug() << "Loading app config path:" << m_configPath; qDebug() << "Loading app config path:" << m_configPath;
qDebug() << "File exists?" << QFile::exists(m_configPath); qDebug() << "File exists?" << QFile::exists(m_configPath);
qDebug() << "Registry installation id:" << m_registryInstallId; qDebug() << "Registry installation id:" << m_registryInstallId;
@@ -508,7 +508,7 @@ QString ConfigHelper::dataRoot() const
if (base.isEmpty()) if (base.isEmpty())
base = QDir::homePath(); base = QDir::homePath();
return QDir::cleanPath(QDir(base).filePath( 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 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 QString ConfigHelper::getValue(const QString& section, const QString& key) const
{ {
Q_UNUSED(section); Q_UNUSED(section);
const QString embeddedValue = readEmbeddedValue(key);
if (!embeddedValue.isEmpty())
return embeddedValue;
if (!isRegistryManagedConfigKey(key)) if (!isRegistryManagedConfigKey(key))
return QString(); return QString();
QString value; QString value;
if (readRegistryValue(key, &value)) if (readRegistryValue(key, &value))
return 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) bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
@@ -825,14 +827,14 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
}; };
copyText("App", "app_id"); copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App"); copyText("App", "product_code", ini.value("App/app_id").toString());
copyText("App", "app_name", "SimCAE");
copyText("App", "channel", "stable"); copyText("App", "channel", "stable");
copyText("App", "current_version", "1.0.0"); copyText("App", "current_version", "1.0.0");
copyText("App", "client_protocol", "3"); copyText("App", "client_protocol", "3");
copyText("App", "launch_token");
copyText("License", "license_key");
copyText("Server", "api_base_url");
copyText("Server", "client_token"); copyText("Server", "client_token");
copyText("App", "launch_token");
copyText("Server", "api_base_url");
copyText("Update", "request_timeout_ms", "5000"); copyText("Update", "request_timeout_ms", "5000");
copyText("Update", "temp_folder", "update_temp"); copyText("Update", "temp_folder", "update_temp");
copyText("Update", "device_id"); copyText("Update", "device_id");
@@ -842,6 +844,9 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater")); copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater"));
copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap")); copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap"));
copyText("Runtime", "health_check_timeout_ms", "15000"); 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 #ifdef Q_OS_WIN
config.insert("platform", "windows"); config.insert("platform", "windows");
#elif defined(Q_OS_LINUX) #elif defined(Q_OS_LINUX)
@@ -849,7 +854,7 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
#else #else
config.insert("platform", "unknown"); config.insert("platform", "unknown");
#endif #endif
config.insert("arch", "x64"); config.insert("arch", "x86_64");
QDir().mkpath(QFileInfo(m_configPath).path()); QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath); QSaveFile output(m_configPath);
-323
View File
@@ -1,323 +0,0 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSysInfo>
#include <QTimer>
#include <QUuid>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#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<const unsigned char *>(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", "<empty response>") : 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;
}
-28
View File
@@ -1,28 +0,0 @@
#pragma once
#include <QByteArray>
#include <QString>
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;
};
+103 -39
View File
@@ -4,36 +4,53 @@
#include <QFile> #include <QFile>
#include <QDir> #include <QDir>
#include <QApplication> #include <QApplication>
#include <QJsonParseError>
#include <QTimer> #include <QTimer>
#include <QUrl>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, namespace {
std::function<void(int code, const QJsonObject& resp)> callback)
int requestTimeoutMs()
{ {
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; bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000; 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; QTimer timer;
timer.setSingleShot(true); timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() { QObject::connect(&timer, &QTimer::timeout, [&]() {
@@ -42,26 +59,73 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
reply->abort(); reply->abort();
} }
}); });
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs); timer.start(requestTimeoutMs());
loop.exec(); loop.exec();
timer.stop(); timer.stop();
}
} // namespace
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
postRequest(url, jsonBody, QString(), callback);
}
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> 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; int retCode = 0;
QJsonObject retObj; QJsonObject retObj;
readJsonReply(reply, &retCode, &retObj);
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll(); callback(retCode, retObj);
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData; reply->deleteLater();
retObj = QJsonDocument::fromJson(respData).object(); manager->deleteLater();
} }
if (reply->error() != QNetworkReply::NoError)
{ void HttpHelper::getRequest(const QString& url,
qDebug() << "Network error code:" << reply->error(); std::function<void(int code, const QJsonObject& resp)> callback)
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); {
} getRequest(url, QString(), callback);
}
void HttpHelper::getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> 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); callback(retCode, retObj);
+8
View File
@@ -7,6 +7,7 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QEventLoop> #include <QEventLoop>
#include <QDebug> #include <QDebug>
#include <functional>
class HttpHelper class HttpHelper
{ {
@@ -14,4 +15,11 @@ public:
// Create an independent manager for each call instead of keeping it as a member. // Create an independent manager for each call instead of keeping it as a member.
static void postRequest(const QString& url, const QJsonObject& jsonBody, static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback); std::function<void(int code, const QJsonObject& resp)> callback);
static void postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
}; };
+91 -30
View File
@@ -1,5 +1,6 @@
#include "IntegrityHelper.h" #include "IntegrityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
@@ -15,6 +16,32 @@
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #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) IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {} : m_installDir(QDir::cleanPath(installDir)) {}
@@ -22,30 +49,13 @@ QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::safeRelativePath(const QString& path) const bool IntegrityHelper::safeRelativePath(const QString& path) const
{ {
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); return UpdatePathPolicy::isSafeRelativePath(path);
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
} }
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{ {
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。 return UpdatePathPolicy::isFullUpdateProtectedPath(
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。 path, ConfigHelper::instance().runtimeRelativePath());
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> 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);
} }
QString IntegrityHelper::sha256(const QString& filePath) const QString IntegrityHelper::sha256(const QString& filePath) const
@@ -128,15 +138,38 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
return false; return false;
} }
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); if (manifestText.isEmpty())
if (manifestText.isEmpty() || signature.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", m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.") "Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath); .arg(version, cachePath);
return false; 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; 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; QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
@@ -147,19 +180,22 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
return false; return false;
} }
const QJsonObject manifest = manifestDoc.object(); 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("channel").toString() != channel
|| manifest.value("version").toString() != version) { || manifest.value("version").toString() != version) {
m_error = QCoreApplication::translate("IntegrityHelper", 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, .arg(appId, channel, version,
manifest.value("app_id").toString(), manifestProduct,
manifest.value("channel").toString(), manifest.value("channel").toString(),
manifest.value("version").toString()); manifest.value("version").toString());
return false; return false;
} }
QSet<QString> declaredExecutables; QSet<QString> declaredExecutables;
QSet<QString> optionalComponentDirs;
for (const QJsonValue& value : manifest.value("files").toArray()) { for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject(); const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
@@ -169,6 +205,14 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
.arg(version, path); .arg(version, path);
return false; 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; if (runtimeProtectedPath(path)) continue;
const QString fullPath = QDir(m_installDir).filePath(path); const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) { if (!QFile::exists(fullPath)) {
@@ -177,6 +221,17 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
.arg(version, path, fullPath); .arg(version, path, fullPath);
return false; 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 expected = item.value("sha256").toString();
const QString actual = sha256(fullPath); const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
@@ -186,8 +241,6 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual); actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
return false; return false;
} }
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
} }
QDir root(m_installDir); QDir root(m_installDir);
@@ -204,8 +257,16 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|| folded.startsWith(runtimePrefix + "/update_temp/")); || folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/") if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue; || runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); bool optionalComponentFile = false;
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { 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", 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.") "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); .arg(version, relative);
+127
View File
@@ -0,0 +1,127 @@
#include "UpdatePathPolicy.h"
#include <QDir>
#include <QFileInfo>
#include <QSet>
#include <QStringList>
namespace {
bool exactOrRuntimeMatch(const QString& folded, const QString& runtimePrefix,
const QSet<QString>& 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<QString> 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<QString> 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
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <QString>
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);
}
@@ -1,81 +0,0 @@
客户端文档入口
==============
你第一次打开 update-client/Docs 时,先看这一份。这里告诉你每份文档是干什么的,以及不同角色应该从哪里开始。
文档阅读顺序
============
1. 01-客户端接入打包部署指南.md
适合 SDK 接入方、测试人员和交付人员。按“生成 SDK -> 放进业务软件 -> 生成配置 -> 联调 -> 打最终包”的顺序写。
2. 02-编译环境和第三方依赖说明.md
适合需要编译 Launcher、Updater、Bootstrap 的人。说明 Windows/Linux 下 Qt、OpenSSL、thirdparty/ 和 CMake 怎么准备。
3. ../i18n/ReadMe.txt
适合维护界面文案的人。说明新增 tr() 后怎么更新 .ts、生成 .qm,并把翻译文件打进 qrc。
常用任务入口
============
如果你只是拿到 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.jsonLinux 完整格式参考 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 的发布源目录。
@@ -1,432 +0,0 @@
# UpdateClientSDK 客户端接入、打包和部署指南
本文按“维护者打包 SDK -> 你接入业务软件 -> 联调测试 -> 生成最终客户端包”的顺序说明。你拿到这份文档后,按章节一步一步做即可。
## 先看这里:你要做哪件事
| 你的目标 | 直接看哪一节 |
| --- | --- |
| 重新生成给别人用的 SDK 包 | 二、维护者:生成 SDK 包 |
| 把 SDK 放到 SimCAE 或其他业务软件目录 | 三、你:把 SDK 放进业务软件目录 |
| 从后台生成 `app_config.json` 和 qrc 服务端配置 | 四、你:生成并填写客户端配置 |
| 给业务主程序接入启动保护代码 | 五、你:业务主程序接入要求 |
| 验证升级、回滚、健康检查 | 六、你:联调测试 |
| 生成最终交付给用户的客户端包 | 七、维护者:生成最终客户端包 |
## 一、这个 SDK 是什么
UpdateClientSDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力做成一组独立程序,让业务软件通过这些程序完成检查更新、下载、安装、回滚和启动保护。
SDK 核心程序:
- `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 签名公钥,用来验证服务端发布包没有被篡改。
## 二、维护者:生成 SDK 包
这一节是 SDK 维护者操作。接入方通常只需要拿到 `UpdateClientSDK.zip`
打包前确认:
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 版本混用。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.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
```
`dist/UpdateClientSDK.zip` 发给接入方即可。
可选参数:
- `-IncludeDemoMainApp`:把仓库里的 Demo 主程序 `MainApp.exe` 也打进 SDK,方便演示。
- `-IncludeQtRuntime`:把 Qt 运行库也打进 SDK。只有业务软件本身不带 Qt 时才建议使用。
Linux SDK 打包方式:
```bash
cd /home/laluo/project/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
```
Linux SDK 包里核心程序名不带 `.exe`
```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
...
Licenses\
installerResources\
```
推荐把 SDK 放到 `bin` 目录,和 `SimCAE.exe` 同级;后台发布新版本时仍选择整个 `D:\SimCAE\` 作为发布根目录。
先解压 SDK
```powershell
Expand-Archive D:\交付\UpdateClientSDK.zip -DestinationPath D:\SimCAE_SDK -Force
```
再安装到业务软件的 `bin` 目录:
```powershell
cd D:\SimCAE\bin
D:\SimCAE_SDK\scripts\install-sdk.ps1 `
-SdkRoot D:\SimCAE_SDK `
-ReleaseDir .
```
安装后目录应类似:
```text
D:\SimCAE\
bin\
Launcher.exe
Updater.exe
Bootstrap.exe
SimCAE.exe
config\
app_config.json
manifest_public_key.pem
Qt5Core.dll
...
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`
典型配置:
```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",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
```
对应的 `config/server_config.json` 示例:
```json
{
"api_base_url": "http://192.168.229.128:8000"
}
```
## 五、你:业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
接入位置:
```text
main / WinMain 开头,创建主窗口之前
```
当前仓库里的 `update-client/MainApp/main.cpp` 是接入示例,已经实现:
- 启动票据校验。
- 本地 License/设备身份校验。
- 本地策略校验。
- Manifest 完整性校验。
- 健康标记写入。
真正接入业务软件时,把这些启动检查逻辑移植到业务主程序。用户入口应改成 `Launcher.exe`,不要让用户直接双击 `SimCAE.exe`
重要:业务主程序校验 ticket 时,必须使用 SDK 当前运行配置里的动态值,不要直接从 `app_config.json` 读取 `device_id`
原因是 `app_config.json` 是部署源文件,网页生成时 `device_id` 通常为空;真正的设备 ID 是 `Launcher.exe` 首次向服务端登记后写入当前用户注册表的。`Launcher.exe` 生成 ticket 时使用的是注册表里的真实 `device_id`。如果业务主程序从 `app_config.json` 读取空的 `device_id` 来校验,就会出现:
```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,175 +0,0 @@
# 客户端编译环境和第三方依赖说明
`thirdparty/` 是本机依赖目录,已经被 `.gitignore` 忽略,不会提交到 Git。
当前客户端构建依赖:
1. Qt 5.15.2 或兼容的 Qt 5 版本
2. OpenSSL
Windows 下推荐使用 Qt 5.15.2 msvc2019_64 和 OpenSSL-Win64Linux 下使用系统安装的 Qt/OpenSSL 开发包。
先看结论:
- Windows:配置 Qt 环境变量,把 OpenSSL 复制到 `thirdparty/OpenSSL-Win64`
- Linux:用 apt 安装 Qt/OpenSSL 开发包。
- `thirdparty/` 只放本机依赖,不提交 Git。
## 1. Qt 配置
### Windows
Qt 路径由本机环境变量提供。你需要在 Windows 环境变量里配置 Qt 路径,让 CMake 的 `find_package(Qt5 ...)` 能找到 Qt。
推荐配置用户环境变量 `CMAKE_PREFIX_PATH`
```powershell
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
```
设置完成后,重新打开 PowerShell 或 Visual Studio。
如果只想对当前 PowerShell 窗口临时生效:
```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 示例:
```bash
sudo apt update
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev-tools libssl-dev
```
如果 Qt 安装在自定义目录,可以临时设置:
```bash
export CMAKE_PREFIX_PATH=/path/to/Qt/5.x/gcc_64
```
## 2. OpenSSL 配置
### Windows
OpenSSL 默认放在:
```text
thirdparty/OpenSSL-Win64
```
推荐目录结构:
```text
thirdparty/
OpenSSL-Win64/
include/
openssl/
lib/
VC/
x64/
MD/
MDd/
```
复制命令示例:
```powershell
cd C:\Users\admin\Desktop\update-client
mkdir thirdparty
Copy-Item "C:\Program Files\OpenSSL-Win64" ".\thirdparty\OpenSSL-Win64" -Recurse
```
如果 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。
+196 -152
View File
@@ -1,178 +1,267 @@
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QJsonArray>
#include <QApplication>
#include <QDir> #include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include "PolicyHelper.h" #include <QUrl>
#include "LocalStateHelper.h" #include <QUrlQuery>
namespace {
QString trimBaseUrl(QString value)
{
value = value.trimmed();
while (value.endsWith(QLatin1Char('/')))
value.chop(1);
return value;
}
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) UpdateLogic::UpdateLogic(QObject* parent)
: QObject(parent) : QObject(parent)
{ {
ConfigHelper& cfg = ConfigHelper::instance(); ConfigHelper& cfg = ConfigHelper::instance();
m_serverAddr = cfg.getValue("Server", "api_base_url"); m_serverAddr = trimBaseUrl(cfg.getValue("Server", "api_base_url"));
m_appId = cfg.getValue("App", "app_id"); m_appId = cfg.getValue("App", "product_code").trimmed();
m_curVer = cfg.getValue("App", "current_version"); if (m_appId.isEmpty())
m_channel = cfg.getValue("App", "channel"); 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");
// Debug print config
qDebug() << "Read server addr:" << m_serverAddr; qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId; qDebug() << "Read product code:" << m_appId;
} }
void UpdateLogic::checkUpdate() void UpdateLogic::checkUpdate()
{ {
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) m_error.clear();
{
qDebug() << "Config incomplete, abort update check";
m_needUpdate = false; 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; 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) 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; qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code; m_lastStatusCode = code;
m_checkResp = resp; m_checkResp = resp;
m_networkOk = (code == 200); m_networkOk = (code == 200);
if (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; m_needUpdate = false;
m_error = serverMessage(resp);
qDebug() << "Check update api failed:" << m_error;
return; return;
} }
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState()) const QJsonArray releases = resp.value(QStringLiteral("data")).toArray();
{ QJsonObject selected;
qDebug() << "Cannot persist online policy state"; for (const QJsonValue& value : releases) {
m_networkOk = false; 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; m_needUpdate = false;
if (!releases.isEmpty())
m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString();
qDebug() << "No entitled downloadable update for current client.";
return; return;
} }
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString(); 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() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer; qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq(); qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
}); });
} }
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
{ {
QString url = m_serverAddr + "/api/v1/update/download-url"; Q_UNUSED(appId);
QJsonObject body; Q_UNUSED(channel);
body["app_id"] = appId; Q_UNUSED(ver);
body["channel"] = channel; Q_UNUSED(verId);
body["version"] = ver; qDebug() << "SimCAE Hub manifest already contains authorized package download URLs.";
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, bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir) int versionId, const QString& cacheDir)
{ {
Q_UNUSED(versionId);
m_error.clear(); m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) { if (appId.isEmpty() || channel.isEmpty() || version.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic", m_error = QCoreApplication::translate(
"Current version manifest identity is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.") "UpdateLogic",
.arg(appId, channel, version, QString::number(versionId)); "Current version manifest identity is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
.arg(appId, channel, version);
return false; 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; QJsonObject response;
int statusCode = 0; int statusCode = 0;
QJsonObject body; m_http.getRequest(url.toString(QUrl::FullyEncoded),
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) { [&](int code, const QJsonObject& resp) {
statusCode = code; statusCode = code;
response = resp; response = resp;
}); });
if (statusCode != 200) { if (statusCode != 200) {
const QJsonValue detail = response.value(QStringLiteral("detail")); const QString message = serverMessage(response);
const QString message = detail.isObject() m_error = QCoreApplication::translate(
? detail.toObject().value(QStringLiteral("msg")).toString() "UpdateLogic",
: detail.toString(); "Cannot download manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. Product: %2, channel: %3, version: %4.%5")
m_error = QCoreApplication::translate("UpdateLogic", .arg(QString::number(statusCode), appId, channel, version,
"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)); message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
return false; return false;
} }
const QJsonObject manifest = response.value("manifest").toObject(); QJsonObject wrapper = responseDataObject(response);
const QString manifestText = response.value("manifest_text").toString(); 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()) { if (manifest.isEmpty() || manifestText.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic", m_error = QCoreApplication::translate(
"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.") "UpdateLogic",
.arg(appId, channel, version, QString::number(versionId)); "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; return false;
} }
if (manifest.value("app_id").toString() != appId const QString manifestProduct = manifest.value(QStringLiteral("productCode")).toString(
|| manifest.value("channel").toString() != channel manifest.value(QStringLiteral("app_id")).toString());
|| manifest.value("version").toString() != version) { if (manifestProduct != appId
m_error = QCoreApplication::translate("UpdateLogic", || manifest.value(QStringLiteral("channel")).toString() != channel
"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.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, .arg(appId, channel, version,
manifest.value("app_id").toString(), manifestProduct,
manifest.value("channel").toString(), manifest.value(QStringLiteral("channel")).toString(),
manifest.value("version").toString()); manifest.value(QStringLiteral("version")).toString());
return false; return false;
} }
QDir dir(cacheDir); QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) { if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate("UpdateLogic", m_error = QCoreApplication::translate(
"UpdateLogic",
"Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.") "Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.")
.arg(cacheDir); .arg(cacheDir);
return false; return false;
} }
QJsonObject wrapper; wrapper.insert(QStringLiteral("manifest"), manifest);
wrapper["manifest"] = manifest; wrapper.insert(QStringLiteral("manifestText"), manifestText);
wrapper["manifest_text"] = manifestText; wrapper.insert(QStringLiteral("manifest_text"), manifestText);
QSaveFile file(dir.filePath("manifest_" + version + ".json")); QSaveFile file(dir.filePath(QStringLiteral("manifest_") + version + QStringLiteral(".json")));
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented); const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
m_error = QCoreApplication::translate("UpdateLogic", m_error = QCoreApplication::translate(
"Cannot save signed manifest cache. Stage: cache current version manifest. File: %1. Error: %2.") "UpdateLogic",
"Cannot save manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
.arg(file.fileName(), file.errorString()); .arg(file.fileName(), file.errorString());
return false; return false;
} }
@@ -182,62 +271,17 @@ bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, co
bool UpdateLogic::refreshGitTagsFile(const QString& outputPath) bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
{ {
Q_UNUSED(outputPath);
m_error.clear(); m_error.clear();
if (m_serverAddr.isEmpty()) { qDebug() << "Git tag export is not part of the current SimCAE Hub admin pages; skipped.";
m_error = QCoreApplication::translate("UpdateLogic", "Server address is empty.");
return false;
}
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,
[&](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;
return false;
}
const QString tagsText = response.value("tags_text").toString();
if (tagsText.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic", "Git tags response is empty.");
return false;
}
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(outputPath, tagsText.toUtf8(), &writeError)) {
m_error = QCoreApplication::translate("UpdateLogic", "Cannot write Git tags file: %1").arg(writeError);
return false;
}
qDebug() << "Git tags file refreshed:" << outputPath;
return true; return true;
} }
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
{ {
QString url = m_serverAddr + "/api/v1/update/report"; Q_UNUSED(deviceId);
QJsonObject body; Q_UNUSED(fromVer);
body["app_id"] = m_appId; Q_UNUSED(toVer);
body["device_id"] = deviceId; Q_UNUSED(success);
body["from_version"] = fromVer; qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
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;
});
} }
+1
View File
@@ -25,6 +25,7 @@ public:
QString errorString() const { return m_error; } QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; } QString getAppId() const { return m_appId; }
QString getProductCode() const { return m_appId; }
QString getChannel() const { return m_channel; } QString getChannel() const { return m_channel; }
private: private:
+98 -288
View File
@@ -1,28 +1,75 @@
#include <QApplication> #include <QApplication>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QInputDialog>
#include <QLineEdit>
#include <QTranslator> #include <QTranslator>
#include <QUuid>
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include <QFile> namespace {
#include <QFileDialog>
#include <QDir> 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[]) int main(int argc, char* argv[])
{ {
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher"); QApplication::setApplicationName("SimCAE Launcher");
QTranslator translator; QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm")) if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
app.installTranslator(&translator); app.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
@@ -30,7 +77,7 @@ int main(int argc, char* argv[])
return elevatedWriteExitCode; return elevatedWriteExitCode;
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0); QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher")); progress.setWindowTitle(QCoreApplication::translate("Launcher", "SimCAE Launcher"));
progress.setCancelButton(nullptr); progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
@@ -38,94 +85,11 @@ int main(int argc, char* argv[])
progress.show(); progress.show();
QApplication::processEvents(); QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) { const QString appDir = QApplication::applicationDirPath();
const QString text = errorText.toLower();
return text.contains("license") progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates..."));
|| 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(); 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; UpdateLogic logic;
logic.checkUpdate(); logic.checkUpdate();
@@ -133,85 +97,25 @@ int main(int argc, char* argv[])
const bool networkOk = logic.isNetworkOk(); const bool networkOk = logic.isNetworkOk();
const QString latestVer = logic.getLatestVersion(); const QString latestVer = logic.getLatestVersion();
const QJsonObject response = logic.getCheckResult(); const QJsonObject response = logic.getCheckResult();
const int targetVersionId = response.value("version_id").toInt(); const QString releaseId = response.value(QStringLiteral("release_id")).toString(
const QString appId = logic.getAppId(); response.value(QStringLiteral("id")).toString());
const QString appId = logic.getProductCode();
const QString channel = logic.getChannel(); const QString channel = logic.getChannel();
const QString launchToken = config.getValue("App", "launch_token"); const QString launchToken = config.getValue(QStringLiteral("App"), QStringLiteral("launch_token"));
const QString currentVersion = config.getValue("App", "current_version"); const QString currentVersion = config.getValue(QStringLiteral("App"), QStringLiteral("current_version"));
const QString deviceId = ensureDeviceId();
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 auto configuredName = [&](const QString& key, const QString& fallback) { const auto configuredName = [&](const QString& key, const QString& fallback) {
return ConfigHelper::executableNameForCurrentPlatform( return ConfigHelper::executableNameForCurrentPlatform(
config.getValue("Runtime", key), fallback); config.getValue(QStringLiteral("Runtime"), key), fallback);
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp"); const QString mainExecutable = configuredName(QStringLiteral("main_executable"), QStringLiteral("MainApp"));
const QString updaterExecutable = configuredName("updater_executable", "Updater"); const QString updaterExecutable = configuredName(QStringLiteral("updater_executable"), QStringLiteral("Updater"));
const QString mainAppPath = QDir(appDir).filePath(mainExecutable); const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable); 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; QString mainStartupError;
const auto startMainApp = [&]() { const auto startMainApp = [&]() {
// 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。
// 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。
mainStartupError.clear(); mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) { if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate( mainStartupError = QCoreApplication::translate(
@@ -220,9 +124,10 @@ int main(int argc, char* argv[])
.arg(mainAppPath); .arg(mainAppPath);
return false; return false;
} }
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, if (!TicketHelper::createTicket(appId, deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) { launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate( mainStartupError = QCoreApplication::translate(
@@ -231,8 +136,9 @@ int main(int argc, char* argv[])
.arg(mainAppPath, ticketError); .arg(mainAppPath, ticketError);
return false; return false;
} }
const bool started = QProcess::startDetached(mainAppPath, const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)}); QStringList{QStringLiteral("--ticket-file=%1").arg(ticketPath)});
if (!started) { if (!started) {
QFile::remove(ticketPath); QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate( mainStartupError = QCoreApplication::translate(
@@ -243,96 +149,32 @@ int main(int argc, char* argv[])
return started; return started;
}; };
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy...")); if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
QApplication::processEvents();
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"), QCoreApplication::translate("Launcher", "Update Client Rejected"),
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString())); authFailureMessage(response,
QCoreApplication::translate("Launcher", "The update client token is missing or invalid. Please download a valid package from the customer portal.")));
return -1; return -1;
} } else if (!networkOk) {
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())
{
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."));
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(); progress.close();
QMessageBox::warning(nullptr, QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "Git Tag List Failed"), QCoreApplication::translate("Launcher", "Update Server Unavailable"),
QCoreApplication::translate("Launcher", "Cannot generate tags.txt, but startup will continue:\n%1").arg(logic.errorString())); QCoreApplication::translate("Launcher", "Cannot connect to the update server. The installed application will be started without downloading an update."));
progress.show(); progress.show();
QApplication::processEvents();
}
} }
if (networkOk && needUpdate) if (networkOk && needUpdate)
{ {
progress.close(); progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; const QString prompt = QCoreApplication::translate(
const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback") "Launcher",
: (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available")); "Version %1 is available. Update now?").arg(latestVer);
const QString prompt = policy.message().isEmpty() const bool accepted = QMessageBox::question(nullptr,
? (rollbackOperation QCoreApplication::translate("Launcher", "New Version Available"),
? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer) prompt,
: QCoreApplication::translate("Launcher", "Version %1 is available. Update now?").arg(latestVer)) QMessageBox::Yes | QMessageBox::No,
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer); QMessageBox::No) == QMessageBox::Yes;
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;
}
if (!accepted) { if (!accepted) {
if (!startMainApp()) { if (!startMainApp()) {
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr,
@@ -344,7 +186,14 @@ int main(int argc, char* argv[])
} }
return 0; 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)) if (!QFileInfo::exists(updaterPath))
{ {
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr,
@@ -368,48 +217,9 @@ int main(int argc, char* argv[])
return 0; 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 progress.setLabelText(networkOk
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...") ? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting...")); : QCoreApplication::translate("Launcher", "Offline startup. Starting..."));
QApplication::processEvents(); QApplication::processEvents();
if (!startMainApp()) if (!startMainApp())
{ {
+38 -41
View File
@@ -1,69 +1,66 @@
#include "MainWindow.h" #include "MainWindow.h"
#include <QApplication> #include <QApplication>
#include <QFont> #include <QCryptographicHash>
#include <QFile> #include <QFile>
#include <QFont>
#include <QLabel> #include <QLabel>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QCryptographicHash>
#include "../Common/PolicyHelper.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
static QString readDllVersion(const QString& dllPath) 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); QFile file(dllPath);
if (!file.exists()) if (!file.exists())
return "missing"; return QStringLiteral("missing");
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
return "unreadable"; return QStringLiteral("unreadable");
QByteArray data = file.read(1024); const QByteArray data = file.read(1024);
file.close(); file.close();
return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8)); return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8));
} }
} // namespace
MainWindow::MainWindow(QWidget* parent) MainWindow::MainWindow(QWidget* parent)
: QWidget(parent) : QWidget(parent)
{ {
this->setWindowTitle("Marsco Demo MainApp"); this->setWindowTitle("SimCAE Demo MainApp");
this->resize(600, 400); this->resize(600, 400);
QString appVersion = ConfigHelper::instance().getValue("App", "current_version"); const QString appVersion = configValue(QStringLiteral("current_version"), QStringLiteral("unknown"));
if (appVersion.isEmpty()) const QString product = configValue(QStringLiteral("product_code"),
appVersion = "unknown"; configValue(QStringLiteral("app_id"), QStringLiteral("unknown")));
const QString channel = configValue(QStringLiteral("channel"), QStringLiteral("stable"));
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); const QString apiBaseUrl = configValue(QStringLiteral("api_base_url"), QStringLiteral("not configured"));
const QString tokenState = configValue(QStringLiteral("client_token")).isEmpty()
PolicyHelper policy(QApplication::applicationDirPath()); ? QStringLiteral("missing")
QString policyResult; : QStringLiteral("configured");
if (!policy.loadPolicy("config/version_policy.dat")) const QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
{
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); QVBoxLayout* layout = new QVBoxLayout(this);
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3") QLabel* label = new QLabel(QString(
.arg(appVersion) "Software Running Successfully\n"
.arg(dllVersion) "Product: %1\n"
.arg(policyResult)); "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(); QFont font = label->font();
font.setPointSize(14); font.setPointSize(13);
label->setFont(font); label->setFont(font);
label->setAlignment(Qt::AlignCenter); label->setAlignment(Qt::AlignCenter);
+48 -68
View File
@@ -1,23 +1,46 @@
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QMessageBox>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QMessageBox>
#include <QSaveFile> #include <QSaveFile>
#include <QTranslator> #include <QTranslator>
#include "MainWindow.h" #include "MainWindow.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h" #include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.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[]) int main(int argc, char* argv[])
{ {
QApplication a(argc, argv); QApplication a(argc, argv);
QTranslator translator; QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm")) if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
a.installTranslator(&translator); a.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
@@ -27,24 +50,26 @@ int main(int argc, char* argv[])
qDebug() << Qt::endl << "entered main app" << Qt::endl; qDebug() << Qt::endl << "entered main app" << Qt::endl;
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); QString launcherExecutable = config.getValue(QStringLiteral("Runtime"), QStringLiteral("launcher_executable")).trimmed();
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher"); launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, QStringLiteral("Launcher"));
QString ticketFilePath; QString ticketFilePath;
QString healthFilePath; QString healthFilePath;
for (int i = 1; i < argc; ++i) for (int i = 1; i < argc; ++i)
{ {
const QString arg(argv[i]); const QString arg(argv[i]);
if (arg.startsWith("--ticket-file=")) if (arg.startsWith(QStringLiteral("--ticket-file=")))
ticketFilePath = arg.mid(QString("--ticket-file=").size()); ticketFilePath = arg.mid(QStringLiteral("--ticket-file=").size());
else if (arg.startsWith("--health-file=")) else if (arg.startsWith(QStringLiteral("--health-file=")))
healthFilePath = arg.mid(QString("--health-file=").size()); healthFilePath = arg.mid(QStringLiteral("--health-file=").size());
} }
QString ticketError; QString ticketError;
if (ticketFilePath.isEmpty() if (ticketFilePath.isEmpty()
|| !TicketHelper::consumeAndVerify(ticketFilePath, || !TicketHelper::consumeAndVerify(ticketFilePath,
config.getValue("App", "app_id"), config.getValue("Update", "device_id"), productCode(), config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"), config.getValue(QStringLiteral("App"), QStringLiteral("current_version")),
config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")),
&ticketError)) &ticketError))
{ {
QMessageBox::critical(nullptr, "Startup Restriction", QMessageBox::critical(nullptr, "Startup Restriction",
@@ -53,67 +78,19 @@ int main(int argc, char* argv[])
return -1; return -1;
} }
QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot(); const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir); if (configFlag(QStringLiteral("verify_installed_on_start"))) {
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); IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion( if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"), productCode(),
config.getValue("App", "current_version"))) config.getValue(QStringLiteral("App"), QStringLiteral("channel")),
config.getValue(QStringLiteral("App"), QStringLiteral("current_version"))))
{ {
QMessageBox::critical(nullptr, "Integrity Check Failed", QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Startup blocked because the installed files failed local signed Manifest verification.\n" QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1")
"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())); .arg(integrity.errorString()));
return -1; 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()) if (!healthFilePath.isEmpty())
@@ -130,6 +107,9 @@ int main(int argc, char* argv[])
} }
} }
MainWindow w;
w.show();
qDebug() << "Main program MainApp is running normally"; qDebug() << "Main program MainApp is running normally";
return a.exec(); return a.exec();
} }
+265
View File
@@ -0,0 +1,265 @@
# SimCAE Hub 更新客户端
`update-client` 是 SimCAE Hub 的 Qt/C++ 整包更新客户端,包含 `Launcher``Updater``Bootstrap` 和一个示例 `MainApp`。它负责检查整包更新、拉取 Manifest、下载发布包、校验哈希并完成本地安装。
组件级安装、组件级更新和卸载由 Qt IFW 生成的 `maintenancetool.exe` 负责。`update-client` 不替代 MaintenanceTool,也不读取 Qt IFW 的 `Updates.xml`
## 一、当前接入方式
当前 SIMCAE 的标准接入方式是:
| 阶段 | 发生什么 |
| --- | --- |
| SDK 打包 | `update-client` 只打出 `Launcher.exe``Updater.exe``Bootstrap.exe` 和运行库 |
| SIMCAE 打包 | SIMCAE 的 `installer` 规则把 SDK 文件放进核心组件 `com.simcae.app` |
| 本地 IFW package | Hub 更新客户端位于 `package/packages/com.simcae.app/data/view/bin` |
| 上传到 Hub | 服务端校验 IFW 交付包,并自动注入最终客户配置 |
| 客户安装 | 客户从门户下载安装器,安装后得到 `maintenancetool.exe``view/bin/Launcher.exe` |
| 日常启动 | 客户通过 `Launcher.exe` 或安装器创建的快捷方式启动 SimCAE |
最终客户不需要手动填写 `app_config.json`、服务器地址、token、产品编码、平台架构或版本号。
## 二、程序组成
| 程序 | 作用 |
| --- | --- |
| `Launcher` | 客户日常启动入口,读取配置、检查整包更新、启动 `Updater` 或主程序 |
| `Updater` | 拉取 Manifest、下载发布包、校验文件、准备安装事务 |
| `Bootstrap` | 替换运行中文件,并把安装结果交回 `Updater` |
| `MainApp` | 示例主程序,用于验证 launch ticket 和启动前完整性校验 |
| `Common` | 配置、HTTP、票据、路径、Manifest 和完整性校验等公共代码 |
真实接入 SIMCAE 时,`MainApp` 只是示例程序。正式主程序是 SIMCAE 自己的 `SimCAE.exe`
## 三、在线更新链路
1. 客户启动 `Launcher.exe`
2. 客户端读取服务端注入的初始配置。
3. 首次启动时,客户端会把 `app_config.json` 中的运行配置导入本机用户配置。
4. 为减少明文配置暴露,导入成功后客户端可能清空安装目录里的 `app_config.json`
5. `Launcher` 确保本机有 `device_id`
6. `Launcher` 使用 `X-Client-Token` 调用更新检查接口。
7. 如果服务端返回可用发布,`Launcher` 启动 `Updater`
8. `Updater` 使用 `X-Client-Token` 拉取 Manifest。
9. `Updater` 校验 Manifest 摘要,并按配置决定是否要求签名。
10. `Updater` 按 Manifest 下载文件。
11. 每个文件下载完成后校验大小和 SHA-256。
12. 安装前校验 staging 目录。
13. 如需替换运行中文件,`Bootstrap` 接管安装。
14. 安装完成后保存 Manifest 缓存和本地状态。
15. 如果主程序开启启动前完整性校验,下次启动时会按本地 Manifest 缓存校验已安装文件。
更新接口使用部署级 `client_token`,不使用客户邮箱密码。客户账号和授权主要控制门户下载、客户权益和席位,不要求最终客户启动软件时再登录。
## 四、当前 SIMCAE 目录规则
客户安装完成后的关键目录是:
| 路径 | 说明 |
| --- | --- |
| `maintenancetool.exe` | Qt IFW 生成的组件维护工具,位于安装根目录 |
| `components.xml` | Qt IFW 记录的已安装组件状态,位于安装根目录 |
| `network.xml` | Qt IFW 记录的组件仓库地址,位于安装根目录 |
| `view/bin/Launcher.exe` | Hub 更新客户端启动入口 |
| `view/bin/Updater.exe` | Hub 整包更新程序 |
| `view/bin/Bootstrap.exe` | Hub 安装接管程序 |
| `view/bin/SimCAE.exe` | SIMCAE 业务主程序 |
| `view/bin/config/app_config.json` | 服务端注入的初始客户配置 |
当前服务端会识别三种运行目录:
| 运行目录 | 服务端写入的 `install_root` | 说明 |
| --- | --- | --- |
| 软件根目录 | `.` | `Launcher` 和主程序就在软件根目录 |
| `bin` | `..` | `Launcher` 在一层 `bin` 目录中 |
| `view/bin` | `../..` | 当前 SIMCAE 标准结构,`Launcher``view/bin` 中 |
`install_root` 不是让客户手动填写的字段。上传发布包或 Qt IFW 交付包时,服务端会根据 `Launcher``Updater``Bootstrap` 的实际位置自动判断。
## 五、服务端注入的配置
正式客户包中的 `app_config.json` 由服务端生成或替换。开发者打 SDK 时不放最终配置,客户也不手动改配置。
服务端生成配置时,信息来源如下:
| 配置内容 | 来源 |
| --- | --- |
| `product_code``app_id` | 管理后台“产品目录”的产品编码 |
| `app_name` | 管理后台“产品目录”的产品名称 |
| `channel` | 管理后台“软件发布”的发布通道 |
| `current_version` | 管理后台“产品版本”的版本号 |
| `platform``arch``abi` | 管理后台“平台管理”和发布包选择的平台 |
| `api_base_url` | 服务端 `.env``SIMCAE_CLIENT_API_BASE_URL` |
| `client_token` | 服务端 `.env``SIMCAE_CLIENT_TOKEN` |
| `launch_token` | 服务端 `.env``SIMCAE_LAUNCH_TOKEN` |
| `install_root` | 服务端根据运行目录自动判断 |
| `main_executable` | 服务端在运行目录中识别到的业务主程序,SIMCAE 当前为 `SimCAE.exe` |
| `launcher_executable` | 按平台生成,Windows 为 `Launcher.exe` |
| `updater_executable` | 按平台生成,Windows 为 `Updater.exe` |
| `bootstrap_executable` | 按平台生成,Windows 为 `Bootstrap.exe` |
| `require_manifest_signature` | 服务端 Manifest 签名配置 |
| `verify_installed_on_start` | 当前服务端默认写入 `false`,需要强制启动校验时再按发布策略开启 |
如果上传包里已经带了旧的 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`,服务端会按当前发布信息重新处理,不让开发机临时配置直接进入最终客户包。
## 六、本地调试配置
正式发布不要手写最终 `app_config.json`。如果开发者只是在本机调试 `Launcher``Updater`,可以创建未提交的 `config/app_config.local.json`
CMake 只在本地输出目录还没有 `config/app_config.json` 时,才会把 `app_config.local.json` 复制成调试用配置。这个文件只服务本机调试,不代表服务端最终注入结果。
`config/server_config.json``config/server_config.qrc` 用于把兜底 API 地址编译进 EXE。正式客户包优先使用服务端注入的 `api_base_url`,一般不需要让客户看到或修改 `server_config.json`
## 七、启动门禁
如果 SIMCAE 主程序开启 `SimCAE_UseLauncher=ON`,用户直接双击 `SimCAE.exe` 会被拦截,必须通过 `Launcher.exe` 启动。
这套机制依赖 `launch_token`
| 位置 | 要求 |
| --- | --- |
| 服务端 `.env` | 必须配置 `SIMCAE_LAUNCH_TOKEN` |
| SIMCAE 编译期 | 主程序编译时使用同一个 token |
| 客户端配置 | 服务端把同一个 token 写入最终客户配置 |
如果三处 token 不一致,就会出现“直接双击被拦住,但从 `Launcher` 启动也失败”的问题。
## 八、Manifest 和哈希校验
整包更新使用 SimCAE Hub Manifest,不使用 Qt IFW 的 `Updates.xml`
Manifest 负责描述:
| 内容 | 说明 |
| --- | --- |
| 发布版本 | 本次更新属于哪个产品、版本线、版本和通道 |
| 文件清单 | 本次发布包含哪些文件 |
| 下载地址 | 每个文件从哪个受控接口下载 |
| 文件大小 | 客户端下载后必须一致 |
| SHA-256 | 客户端下载后必须一致 |
| 是否必选 | 必选文件缺失会阻止启动,可选组件文件可由 MaintenanceTool 管理 |
服务端返回 Manifest 前会重新检查发布包文件是否存在、大小是否一致、SHA-256 是否一致。客户端下载完成后也会再次校验大小和 SHA-256。
## 九、和 MaintenanceTool 的边界
| 能力 | 使用程序 | 文件格式 |
| --- | --- | --- |
| 整包更新 | `Launcher``Updater``Bootstrap` | SimCAE Hub 发布包和 Manifest |
| 组件安装、更新、移除 | `maintenancetool.exe` | Qt IFW repository 和 `Updates.xml` |
两条线可以共存,但不要混淆:
1. `Updater` 不读取 `Updates.xml`
2. `MaintenanceTool` 不读取 SimCAE Hub Manifest。
3. `Updater` 不拉起 `MaintenanceTool`
4. `MaintenanceTool` 由客户手动打开,或由 Qt IFW 自己的流程使用。
5. 核心组件通常包含 `Launcher``Updater``Bootstrap``SimCAE.exe`
6. 可选组件例如 DAP 插件,可以由 MaintenanceTool 单独安装、更新或移除。
## 十、编译环境
| 依赖 | 要求 |
| --- | --- |
| CMake | 建议 3.20 或更高版本 |
| C++ | C++17 |
| Qt | Qt 5,至少需要 Core、Network、Gui、Widgets |
| OpenSSL | 用于 Manifest RSA-SHA256 验签 |
| 编译器 | Windows 推荐 Visual Studio 2022 x64Linux 推荐 gcc/g++ |
项目提供的 CMake Preset
| Preset | 平台 | 用途 |
| --- | --- | --- |
| `x64-debug` | Windows | Debug 编译 |
| `x64-release` | Windows | Release 编译 |
| `linux-x64-debug` | Linux | Debug 编译 |
| `linux-x64-release` | Linux | Release 编译 |
## 十一、Windows 编译
建议安装 Visual Studio 2022、Qt 5 x64、CMake 和 OpenSSL x64。
如果 Qt 没有加入环境变量,可以在编译前指定:
```powershell
$env:CMAKE_PREFIX_PATH = "C:\Qt\5.15.2\msvc2019_64"
```
当前测试机 OpenSSL 路径是 `C:\Program Files\OpenSSL-Win64`Release 编译命令:
```powershell
cmake --preset x64-release -DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
cmake --build --preset x64-release
```
如果 OpenSSL 安装在其他目录,只改 `SIMCAE_OPENSSL_ROOT` 这一项。
Windows Release 产物通常输出到 `out/bin/Release`
检查核心程序:
```powershell
Test-Path .\out\bin\Release\Launcher.exe
Test-Path .\out\bin\Release\Updater.exe
Test-Path .\out\bin\Release\Bootstrap.exe
```
预期都返回 `True`
## 十二、Linux 编译
Ubuntu 示例:
```bash
sudo apt update
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev qttools5-dev-tools libssl-dev
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
```
Linux 下通常直接使用系统 OpenSSL;如果需要指定自定义 OpenSSL,也可以通过 CMake 变量配置。
## 十三、打包 SDK
SDK 打包流程见当前目录 [updater打包成SDK.md](updater打包成SDK.md)。SIMCAE 拿到 SDK 后的安装器、交付包和上传流程见 [SIMCAE打包上传.md](SIMCAE打包上传.md)。
常用输出:
| 输出 | 说明 |
| --- | --- |
| `dist\UpdateClientSDK` | 不带 Qt 运行库的 SDK 展开目录 |
| `dist\UpdateClientSDK.zip` | 不带 Qt 运行库的 SDK 压缩包 |
| `dist\UpdateClientSDK-With-QtDll` | 带 Qt 运行库 DLL 的 SDK 展开目录 |
| `dist\UpdateClientSDK-With-QtDll.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
SDK 不包含最终客户配置。`With-QtDll` 表示包里带的是运行所需的 Qt DLL,不是完整 Qt SDK。SDK 的目标是给 SIMCAE 打包流程提供更新客户端程序和运行库。
## 十四、运行数据位置
Windows 运行配置会导入当前用户配置,按安装运行目录计算 installation id。运行数据目录通常位于:
`%LOCALAPPDATA%\SimCAE\HubUpdateClient\installations\<安装目录SHA256>\`
Linux 运行数据目录通常位于:
`$XDG_DATA_HOME/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
未设置 `XDG_DATA_HOME` 时通常是:
`~/.local/share/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
Manifest 缓存保存在运行数据目录下的 `update/manifest_cache`
## 十五、常见问题
| 现象 | 排查方向 |
| --- | --- |
| 客户启动后提示配置不完整 | 检查交付包是否经过 SimCAE Hub 上传注入,不要直接拿本地未注入包给客户 |
| 检查更新没有结果 | 检查后台发布是否已发布、发布包是否可用、产品编码、通道和平台是否一致 |
| 下载返回 401 | 检查客户包里的 `client_token` 是否来自当前服务端 `.env` |
| Manifest 校验失败 | 检查服务端文件是否被手工改过,大小和 SHA-256 是否与数据库一致 |
| 强制签名失败 | 检查 `manifest_public_key.pem` 与服务端私钥是否匹配 |
| 主程序启动失败 | 检查 `main_executable` 和运行目录是否正确 |
| 从 `Launcher` 启动也被拦截 | 检查服务端、SIMCAE 编译期和客户配置中的 `launch_token` 是否一致 |
| MaintenanceTool 看不到组件更新 | 检查 IFW repository 地址、`Updates.xml` 和组件版本是否正确 |
+621
View File
@@ -0,0 +1,621 @@
# SIMCAE 打包上传
本文站在 SIMCAE 开发者和发布人员的角度,说明拿到 Hub 更新客户端 SDK 后,SIMCAE 怎么打安装器、怎么生成 Qt IFW 交付包、怎么上传到 SimCAE Hub。
服务端部署流程见 simcae-hub 项目根目录《服务端部署.md》。普通发布人员只需要拿到已经打好的更新客户端 SDK;如果需要重新生成 SDK,见源代码仓库 `SIMCAE/update-client/updater打包成SDK.md`
本文下面的命令默认在 SIMCAE 项目根目录执行。下面用 SIMCAE 当前放在 simcae-hub 项目里的情况举例:
```powershell
cd .\SIMCAE
```
进入后再使用相对路径,例如 `.\installer``.\update-client``.\out\build\...`。这样不要求开发者的 SIMCAE 一定放在某个固定磁盘目录。
## 一、先理解交付物
客户端交付会涉及三类文件:
| 交付物 | 给谁用 | 作用 |
| --- | --- | --- |
| Hub 更新客户端 SDK | SIMCAE 开发者 | 提供 `Launcher.exe``Updater.exe``Bootstrap.exe` 和必要运行库 |
| Qt IFW 交付包 ZIP | 上传到 SimCAE Hub | 包含 IFW package 和 repository,服务端会校验、注入配置、发布仓库并重新生成客户安装器 |
| 客户安装器 | 最终客户 | 客户从门户下载后双击安装,安装后得到 `maintenancetool.exe` |
正式主线是:开发者只上传一个 Qt IFW 交付包 ZIP,客户只从门户下载客户安装器。客户不需要手动改服务器地址、token 或 `app_config.json`
## 二、准备 Hub 更新客户端 SDK
开发者应拿到 `UpdateClientSDK-With-QtDll.zip`
这个 ZIP 由 `update-client` 仓库的 SDK 打包脚本生成,SDK 维护者按源代码仓库 `SIMCAE/update-client/updater打包成SDK.md` 操作即可。
建议手动解压到 SIMCAE 仓库内的固定相对目录:`.\update-client\dist\UpdateClientSDK-With-QtDll`
这里的 `With-QtDll` 表示包里带的是运行所需的 Qt DLL,不是完整 Qt SDK。
如果公司内部统一把 SDK 放在别的位置,也可以,只要后面 `$UpdateClientSdk` 指向解压后的 SDK 目录即可。
解压后至少应有:
- `bin\Launcher.exe`
- `bin\Updater.exe`
- `bin\Bootstrap.exe`
SDK 包不应该包含最终客户配置,例如 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`。这些最终配置由服务端在上传发布包时生成或注入。
## 三、准备 SIMCAE 已编译产物
默认 SIMCAE 已经在开发机上完成 Release 编译。客户端打包文档不要求每次重新全量编译 SIMCAE,因为 SIMCAE 工程很大,打安装包时通常只需要复用已有 Release 产物。
需要确认:
| 内容 | 说明 |
| --- | --- |
| SIMCAE Release 构建目录 | 已经存在 `SimCAE.exe`、库文件、资源文件 |
| Qt Installer Framework | 已经安装 `binarycreator.exe``repogen.exe` |
| DAP 运行时 | 如果启用 DAP 组件,`DAPrailCalxml` 等运行时已放在打包规则要求的位置 |
| Hub 更新客户端 SDK | 已解压,并能找到 `Launcher.exe``Updater.exe``Bootstrap.exe` |
如果业务主程序启用了“必须从 Launcher 启动”的门禁,SIMCAE 编译时使用的 launch token 必须和服务端 `.env` 里的 `SIMCAE_LAUNCH_TOKEN` 一致。
## 四、设置本次打包版本
SIMCAE 安装器文件名、IFW 组件 `package.xml` 版本、repository 里的 `Updates.xml` 版本都来自 CMake 变量 `SimCAE_Version`。这个版本默认读取 SIMCAE 仓库最近的纯数字 Git tag,例如 `1.1.3`
先把几个容易混淆的“版本标签”分清楚:
| 名称 | 写在哪里 | 谁会读取 | 作用 |
| --- | --- | --- | --- |
| Git tag | SIMCAE 仓库提交,例如 `git tag 1.1.3` | CMake 版本脚本 | 生成 `SimCAE_Version`,再写入安装器文件名和组件元数据 |
| 组件版本 | `packages/<组件ID>/meta/package.xml``<Version>` | `repogen.exe` | 生成 repository 时写入 `Updates.xml` |
| repository 组件版本 | `Updates.xml` 里的 `<PackageUpdate><Name>...``<Version>...` | `maintenancetool.exe` | 客户端判断某个组件是否需要更新 |
| ZIP 文件名 | 例如 `SimCAE-Delivery-1.1.3-windows_x86_64-msvc.zip` | 人和管理后台记录 | 方便识别上传文件,不是 MaintenanceTool 的更新依据 |
所以 `git tag 1.1.3` 打的是 SIMCAE 源码提交标签,不是给 ZIP 文件打标签。它会被 CMake 读取后间接变成组件 `package.xml` 里的版本。真正决定 MaintenanceTool 是否更新的是服务器 repository 的 `Updates.xml`,而 `Updates.xml` 又来自组件自己的 `package.xml`
正式发布时推荐在 SIMCAE 仓库给本次发布提交打纯数字 tag,再打包:
```powershell
git tag 1.1.3
```
如果只是本机演示,不想改 SIMCAE 仓库 tag,可以临时指定本次打包版本。下面命令会创建一个本地临时脚本,让 CMake 本次配置时读到 `1.1.3`
```powershell
$Version = "1.1.3"
$GitVersionShim = "..\.tmp\git-version-$Version.cmd"
New-Item -ItemType Directory -Force (Split-Path $GitVersionShim) | Out-Null
@"
@echo off
if /I "%1"=="describe" (
echo $Version
exit /b 0
)
git %*
"@ | Set-Content -LiteralPath $GitVersionShim -Encoding ASCII
```
后续所有命令都复用这个 `$Version`。不要只改 ZIP 文件名,否则会出现文件名是 `1.1.3`,但组件 `package.xml``Updates.xml` 里版本还是 `0.10.1` 的错包;这种包上传后,MaintenanceTool 仍然会按 `0.10.1` 判断。
## 五、刷新现有 CMake 打包配置
下面命令只刷新已有构建目录的 CMake 配置,用来告诉打包目标 Qt IFW 在哪里,以及 Hub 更新客户端的本地编译产物和 SDK 兜底在哪里,不是全量重新编译 SIMCAE。
先确认当前 PowerShell 已经在 SIMCAE 项目根目录。下面给出一个常见 Qt IFW 安装路径示例;如果你的 Qt IFW 装在别的位置,只改 `$QtIfwRoot` 这一行。
```powershell
$Build = ".\out\build\SimCAE-release-vs2022-qt515-ifw"
$QtIfwRoot = "C:\Qt\Tools\QtInstallerFramework\4.11"
$HubUpdateClientRuntime = ".\update-client\out\bin\Release"
$UpdateClientSdk = ".\update-client\dist\UpdateClientSDK-With-QtDll"
```
先检查 SIMCAE 工程里的 Hub 更新客户端本地编译产物:
```powershell
Test-Path "$QtIfwRoot\bin\binarycreator.exe"
Test-Path "$QtIfwRoot\bin\repogen.exe"
Test-Path "$HubUpdateClientRuntime\Launcher.exe"
Test-Path "$HubUpdateClientRuntime\Updater.exe"
Test-Path "$HubUpdateClientRuntime\Bootstrap.exe"
```
如果上面三个 EXE 都存在,打 SIMCAE 安装包时会优先使用它们,不会再从 SDK 目录重复拿一份。
如果本地编译产物不存在,再检查 SDK 兜底目录:
```powershell
Test-Path "$UpdateClientSdk\bin\Launcher.exe"
Test-Path "$UpdateClientSdk\bin\Updater.exe"
Test-Path "$UpdateClientSdk\bin\Bootstrap.exe"
```
如果本次要让 `SimCAE.exe` 只能从 `Launcher.exe` 启动,先准备 SIMCAE 编译期使用的本地打包配置。这里的 `launch_token` 必须和服务端 `.env` 里的 `SIMCAE_LAUNCH_TOKEN` 完全一致。
```powershell
$LauncherProductConfig = "..\.tmp\simcae-launcher-product-config.json"
@'
{
"app_id": "simcae",
"product_code": "simcae",
"app_name": "SimCAE",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "SIMCAE_LOCAL_PACKAGING_LICENSE_2026"
}
'@ | Set-Content -LiteralPath $LauncherProductConfig -Encoding UTF8
```
刷新配置:
```powershell
cmake -S . -B $Build `
"-DSimCAE_QtIfwRoot=$QtIfwRoot" `
"-DSimCAE_PackageHubUpdateClient=ON" `
"-DSimCAE_HubUpdateClientRuntimeDir=$HubUpdateClientRuntime" `
"-DSimCAE_HubUpdateClientSdkDir=$UpdateClientSdk" `
"-DSimCAE_UseLauncher=ON" `
"-DSimCAE_LauncherProductConfigFile=$LauncherProductConfig" `
"-DGIT_EXECUTABLE=$GitVersionShim"
```
`SimCAE_PackageHubUpdateClient=ON` 只负责把 `Launcher.exe``Updater.exe``Bootstrap.exe` 放进 SIMCAE 安装包。正式客户配置文件,例如 `config/app_config.json``config/manifest_public_key.pem`,仍然由服务端在上传发布包时生成或注入,不从开发机本地目录带进最终客户包。
如果只想把 Hub 更新客户端打进安装包,但暂时不限制用户直接双击 `SimCAE.exe`,则把上面命令中的 `SimCAE_UseLauncher` 改为 `OFF`,并去掉 `SimCAE_LauncherProductConfigFile` 这一项。
## 六、生成客户安装器和 IFW package
执行打包目标:
```powershell
cmake --build $Build --config Release --target package_installer
```
这个目标会读取 `SIMCAE\installer` 下的配置和组件规则,整理 IFW package staging,并生成安装器。组件有哪些、每个组件包含哪些文件、组件是否必选、依赖哪些组件,应该由 SIMCAE 开发者在打包配置和 `package.xml.in` 里提前定义好;SimCAE Hub 不会自动猜测业务应该拆成哪些组件。
当前示例里已有两个 IFW 组件:
| 组件目录 | 组件含义 | 元数据来源 |
| --- | --- | --- |
| `packages/com.simcae.app` | 核心程序、Launcher、Updater、Bootstrap、核心库和通用资源 | `installer/packages/meta/package.xml.in` |
| `packages/com.simcae.dap` | DAP 求解器插件及运行资源 | `installer/packages/com.simcae.dap/meta/package.xml.in` |
Qt IFW 的组件 ID 来自 `packages/<组件ID>` 目录名,例如 `com.simcae.dap`。组件显示名称、版本、是否强制安装、依赖关系等来自该组件的 `meta/package.xml`,例如 `<DisplayName>``<Version>``<ForcedInstallation>``<Dependencies>`
常见输出:
| 输出 | 说明 |
| --- | --- |
| `$Build\package` | Qt IFW package staging 目录 |
| `$Build\SimCAE-<版本>-Windows-installer.exe` | 本地生成的客户安装器 |
确认核心组件里已经带上 Hub 更新客户端:
```powershell
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\Launcher.exe"
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\Updater.exe"
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\Bootstrap.exe"
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\SimCAE.exe"
```
预期都返回 `True`
再确认 package 里的组件版本就是本次 `$Version`
```powershell
$AppPackageXml = "$Build\package\packages\com.simcae.app\meta\package.xml"
$DapPackageXml = "$Build\package\packages\com.simcae.dap\meta\package.xml"
$AppVersion = ([xml](Get-Content -LiteralPath $AppPackageXml -Encoding UTF8 -Raw)).Package.Version
$DapVersion = ([xml](Get-Content -LiteralPath $DapPackageXml -Encoding UTF8 -Raw)).Package.Version
if ($AppVersion -ne $Version -or $DapVersion -ne $Version) {
throw "组件版本不一致:app=$AppVersion dap=$DapVersion expected=$Version"
}
Test-Path "$Build\SimCAE-$Version-Windows-installer.exe"
```
最后一行预期返回 `True`。如果这里不是 `True`,不要继续生成 repository。
## 七、生成 IFW repository
MaintenanceTool 读取的是 Qt IFW repository,不是客户安装器。
生成前先确认 `$Build\package` 已经存在,并且里面至少有 `config``packages`
```powershell
Test-Path "$Build\package\config\config.xml"
Test-Path "$Build\package\packages"
```
预期都返回 `True`。然后生成完整 repository
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\build-ifw-repository.ps1" `
-PackageDir "$Build\package" `
-OutputDir "$Build\ifw-repository" `
-ZipFile "$Build\SimCAE-IFW-Repository-$Version-windows_x86_64-msvc.zip"
```
这条命令不是“给压缩包打版本标签”。它只是调用 Qt IFW 的 `repogen.exe`,从 `$Build\package\packages` 读取已经准备好的组件目录和 `meta/package.xml`,生成 `Updates.xml` 和组件 `.7z` 包,最后把 repository 目录压成 ZIP。ZIP 文件名里的 `$Version` 只是为了让发布人员识别文件。
生成后检查:
```powershell
Test-Path "$Build\ifw-repository\Updates.xml"
Select-String -LiteralPath "$Build\ifw-repository\Updates.xml" -Pattern "com.simcae.app|com.simcae.dap|<Version>"
```
生成的 repository 根目录必须包含 `Updates.xml`。这个 ZIP 一般不直接给客户,它是给 SimCAE Hub 后端托管,供 `maintenancetool.exe` 后续检查组件更新。
确认 repository 里的组件版本也是本次 `$Version`
```powershell
$UpdatesXml = "$Build\ifw-repository\Updates.xml"
$UpdatesContent = Get-Content -LiteralPath $UpdatesXml -Encoding UTF8 -Raw
if ($UpdatesContent -notmatch "<Version>$([regex]::Escape($Version))</Version>") {
throw "repository Updates.xml 中没有本次版本 $Version"
}
```
## 八、组装 Qt IFW 交付包 ZIP
推荐上传给 SimCAE Hub 的是交付包 ZIP,它把 package 和 repository 放在一起。客户安装器由服务端基于注入配置后的 package 重新生成。
目录结构建议:
- `package/config/`
- `package/packages/com.simcae.app/`
- `package/packages/com.simcae.dap/`
- `repository/Updates.xml`
- `repository/com.simcae.app/`
- `repository/com.simcae.dap/`
不要依赖交付包里的 `installer/SimCAE-<版本>-Windows-installer.exe` 作为最终客户安装器。本地生成的安装器没有服务端注入的 `app_config.json`,客户直接安装后 `Launcher.exe` 会缺少服务器地址、token 和主程序配置。服务端必须配置 `SIMCAE_IFW_BINARYCREATOR_PATH`Linux 服务端生成 Windows 安装器时还必须配置 `SIMCAE_IFW_INSTALLERBASE_WINDOWS_PATH` 指向 Windows 版 `installerbase.exe`,上传后由服务端重新生成客户门户可下载的安装器。
示例命令:
```powershell
$PlatformKey = "windows_x86_64-msvc"
$Bundle = "$Build\SimCAE-Delivery-$Version-$PlatformKey"
if (-not (Test-Path "$Build\ifw-repository\Updates.xml")) {
throw "缺少 repository/Updates.xml,请先生成 IFW repository"
}
Remove-Item -LiteralPath $Bundle -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$Bundle" | Out-Null
Copy-Item "$Build\package" "$Bundle\package" -Recurse -Force
Copy-Item "$Build\ifw-repository" "$Bundle\repository" -Recurse -Force
Compress-Archive -Path "$Bundle\*" -DestinationPath "$Build\SimCAE-Delivery-$Version-$PlatformKey.zip" -Force
```
`release.json` 不需要开发者手写。产品编码、产品名称、版本号、通道、平台、架构和 ABI 来自管理后台表单;运行目录和主程序名由服务端从核心组件中自动识别。
## 九、上传到管理后台
在浏览器打开管理后台,例如 `http://192.168.1.158:1798/login`
按左侧菜单顺序准备基础数据。第一次发布某个产品时要完整走一遍;后续同产品、同通道、同平台发布新版本时,只需要确认这些数据仍然存在:
1. 产品目录:确认产品编码,例如 `simcae`
2. 版本线:确认通道或版本线,例如 `stable`
3. 组件管理:确认核心组件和可选组件,例如 `com.simcae.app``com.simcae.dap`;如果用于 MaintenanceTool 更新,后台组件编码要和 IFW package 的 `packages/<组件ID>` 目录名一致。
4. 平台管理:确认 `windows``x86_64``msvc`
5. 产品版本:创建本次版本,例如 `1.1.3`
6. 软件发布:创建本次发布,关联产品版本和版本线。
7. 发布包:新增或编辑发布包。
上传完整 Qt IFW 交付包时,在“发布包”页面直接点击右上角新增发布包,不需要先点击某个软件卡片。这个入口只用于新建整包更新包和 Qt IFW 交付包。
发布包页面选择:
| 字段 | 建议 |
| --- | --- |
| 包类型 | Qt IFW 交付包 |
| 文件 | `SimCAE-Delivery-<版本>-windows_x86_64-msvc.zip` |
| 平台 | `windows / x86_64 / msvc` |
| 状态 | 上传校验通过后变为可用 |
操作顺序:
1. 打开“发布包”页面。
2. 直接点击右上角“新建发布包”。
3. 包类型选择“Qt IFW 交付包”。此时普通新建入口只应看到“整包更新包”和“Qt IFW 交付包”。
4. 文件名填写本次交付包文件名,例如 `SimCAE-Delivery-1.1.3-windows_x86_64-msvc.zip`
5. 选择产品、版本线、产品版本、发布和平台。
6. 保存发布包记录。
7. 点击该记录的“上传”。
8. 选择本地生成的交付包 ZIP。
9. 等待上传完成,状态应变为“可用”。
上传成功后,服务端会:
- 校验 ZIP 安全路径和 IFW 结构。
- 校验 `package/``repository/Updates.xml`
- 读取组件清单和组件版本。
- 自动注入 `config/app_config.json`
- 自动写入必要的公钥配置。
- 发布 IFW repository。
- 生成或登记客户门户首次下载的安装器。
上传后建议立刻确认:
```powershell
$Base = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc"
(Invoke-WebRequest "$Base/Updates.xml" -UseBasicParsing).Content
```
预期能看到本次版本号、组件 ID 和组件名称。如果这里还是旧版本,先确认发布包状态是否为“可用”,再确认上传时选择的产品、通道和平台是否一致。
## 十、客户下载和安装
客户登录客户门户后,在下载中心下载客户安装器。客户下载到的是 `.exe` 安装器,不是 IFW repository ZIP,也不是开发者上传的交付包 ZIP。
客户安装后,安装目录中应包含:
- `maintenancetool.exe`
- `components.xml`
- `network.xml`
- `view\bin\SimCAE.exe`
- `view\bin\Launcher.exe`
- `view\bin\Updater.exe`
- `view\bin\Bootstrap.exe`
- `view\bin\config\app_config.json`
客户日常启动软件应使用 `Launcher.exe` 或安装器创建的快捷方式。组件更新、添加、移除由 `maintenancetool.exe` 负责。
首次安装建议按这个顺序检查:
1. 打开客户门户。
2. 登录有授权的客户账号。
3. 进入下载中心。
4. 找到对应产品和版本。
5. 点击下载,得到 `SimCAE-<版本>-Windows-installer.exe`
6. 双击安装器,按页面提示完成安装。
7. 安装后进入安装目录,确认 `maintenancetool.exe``components.xml``view\bin\Launcher.exe` 都存在。
8. 双击 `Launcher.exe`,预期能启动 SimCAE。
9. 双击 `maintenancetool.exe`,预期能看到“添加或移除组件”“更新组件”“移除所有组件”。
## 十一、组件更新
这里的“组件”指 `maintenancetool.exe` 里能看到的 Qt IFW 组件,例如 `com.simcae.app``com.simcae.dap`。组件更新就是“只发布某些组件的新版本,或新增一个组件”,让客户后续通过 MaintenanceTool 更新;它不是客户首次安装用的安装器,也不是 Launcher / Updater 用的整包更新 ZIP。
适合使用组件更新的情况:
| 场景 | 应该怎么做 |
| --- | --- |
| 只更新 DAP 插件 | 做一个只包含 `com.simcae.dap` 的组件更新包 |
| 新增示例、模板、插件等可选功能 | 做一个包含新组件的组件更新包 |
| 一次更新几个互相依赖的组件 | 做一个多组件更新包,把这些组件一起放进去 |
| 更新核心程序、Launcher、Updater、Bootstrap 或 `app_config.json` | 更推荐重新发完整 Qt IFW 交付包 |
| 第一次发布某个产品、通道、平台 | 先发完整 Qt IFW 交付包,后面才能发组件更新 |
组件更新包不是单独飘在系统外面的文件。它上传时必须挂到某个产品、某个产品版本、某次发布、某个平台下面。服务端会把它合并到这个产品对应通道和平台的 current repository 中。
### 11.1 组件版本怎么定
组件版本以组件自己的 `package.xml` 为准。比如 DAP 组件的版本写在这里:
- `$Build\package\packages\com.simcae.dap\meta\package.xml`
- XML 节点是 `<Version>1.1.4</Version>`
如果说“组件标签”,这里真正参与更新判断的是组件 ID 和组件版本:组件 ID 来自目录名 `packages/com.simcae.dap`,组件版本来自 `meta/package.xml` 里的 `<Version>`。开发者在维护 IFW package 时就应该把组件拆分、显示名、版本、必选状态和依赖关系写清楚。`repogen.exe` 生成 repository 时,会把这些信息写进 `Updates.xml`。MaintenanceTool 也是根据 `Updates.xml` 里的组件版本判断是否可更新。
当前 SIMCAE 全量打包默认会让所有组件跟随同一个 `$Version`,这个 `$Version` 来自 SIMCAE 仓库的纯数字 Git tag,或者前面文档里的 `$GitVersionShim` 临时版本脚本。例如 `$Version = "1.1.3"` 时,`com.simcae.app``com.simcae.dap` 默认都会变成 `1.1.3`
如果只更新 DAP,不更新核心组件,规则是:
1. 服务器当前 `com.simcae.app``1.1.3``com.simcae.dap``1.1.3`
2. 本次只把 `com.simcae.dap``package.xml` 改成 `1.1.4`
3. 不改 `com.simcae.app``package.xml`,它仍然保持 `1.1.3`
4. 生成只包含 `com.simcae.dap` 的组件更新包。
5. 上传后,服务器 current repository 里应变成 `com.simcae.app=1.1.3``com.simcae.dap=1.1.4`
正式流程里,建议 SIMCAE 打包侧给每个组件提供独立版本参数。当前如果只是本地演示,可以在 `$Build\package\packages\<组件ID>\meta\package.xml` 里调整目标组件的 `<Version>`,然后再生成组件更新包。不要改不更新的组件版本,也不要只改 ZIP 文件名。ZIP 名字里写了 `1.1.4`,但 `package.xml` 仍是 `1.1.3` 时,生成出来的 `Updates.xml` 也会是 `1.1.3`
### 11.2 先确认服务器已有当前仓库
`simcae / stable / windows_x86_64-msvc` 为例:
```powershell
$Base = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc"
(Invoke-WebRequest "$Base/Updates.xml" -UseBasicParsing).Content
```
预期能看到当前仓库的组件,例如:
- `<Name>com.simcae.app</Name>`
- `<Version>1.1.3</Version>`
- `<Name>com.simcae.dap</Name>`
- `<Version>1.1.3</Version>`
如果这里访问失败,先不要上传组件更新包,说明服务器还没有这个产品、通道、平台的 current repository。
### 11.3 准备本地组件产物
开发者先按 SIMCAE 自己的规则把组件文件准备到 IFW package staging 里。组件边界应该在开发和打包配置阶段就已经分好;后面的 repository 生成命令只是读取这些组件,不会自动分析文件并替开发者拆组件。当前打包目标会把组件整理到:
- `$Build\package\packages\com.simcae.app`
- `$Build\package\packages\com.simcae.dap`
如果只更新 DAP 插件,先确认 DAP 组件目录存在:
```powershell
Test-Path "$Build\package\packages\com.simcae.dap\meta\package.xml"
Test-Path "$Build\package\packages\com.simcae.dap\data"
```
预期都返回 `True`。同时要确认 `package.xml` 里的版本已经升高:
```powershell
Select-String -LiteralPath "$Build\package\packages\com.simcae.dap\meta\package.xml" -Pattern "<Version>"
```
例如服务器当前 DAP 是 `1.1.3`,本次 DAP 组件更新包应改成 `1.1.4` 或更高。
### 11.4 生成组件更新 ZIP
使用 `-Include` 只把要更新的组件打进 repository。`-Include "com.simcae.dap"` 里的值是组件 ID,也就是 `packages/com.simcae.dap` 这个目录名;它不是 ZIP 标签,也不是版本号。下面以只更新 DAP 为例:
```powershell
$ComponentVersion = "1.1.4"
$PlatformKey = "windows_x86_64-msvc"
$ComponentUpdateRepository = "$Build\ifw-component-update-com.simcae.dap-$ComponentVersion"
$ComponentUpdateZip = "$Build\SimCAE-IFW-ComponentUpdate-com.simcae.dap-$ComponentVersion-$PlatformKey.zip"
Remove-Item -LiteralPath $ComponentUpdateRepository -Recurse -Force -ErrorAction SilentlyContinue
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\build-ifw-repository.ps1" `
-PackageDir "$Build\package" `
-OutputDir $ComponentUpdateRepository `
-ZipFile $ComponentUpdateZip `
-Include "com.simcae.dap"
```
这条命令不会修改 `com.simcae.dap``<Version>`。它只是根据 `-Include` 选择已有组件,把该组件当前 `package.xml` 中写好的版本、显示名和依赖交给 `repogen.exe`,再生成本次组件更新 repository ZIP。
如果一次更新多个组件:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\build-ifw-repository.ps1" `
-PackageDir "$Build\package" `
-OutputDir "$Build\ifw-component-update-multi-$ComponentVersion" `
-ZipFile "$Build\SimCAE-IFW-ComponentUpdate-multi-$ComponentVersion-$PlatformKey.zip" `
-Include "com.simcae.app","com.simcae.dap"
```
生成后检查 ZIP 对应的展开目录:
```powershell
Test-Path "$ComponentUpdateRepository\Updates.xml"
Get-ChildItem -LiteralPath $ComponentUpdateRepository
Select-String -LiteralPath "$ComponentUpdateRepository\Updates.xml" -Pattern "com.simcae.dap|<Version>|<Dependencies>"
```
单 DAP 更新包的典型结构应类似:
- `Updates.xml`
- `com.simcae.dap/1.1.4meta.7z`
- `com.simcae.dap/1.1.4view.7z`
- `com.simcae.dap/1.1.4view.7z.sha1`
如果 ZIP 解开后外面多套了一层目录,也可以上传;服务端会识别常见外层目录。但推荐让 ZIP 根部直接就是 `Updates.xml` 和组件目录,最不容易出错。
### 11.5 上传组件更新
管理后台操作:
1. 打开“产品版本”,创建本次发布批次版本,例如 `1.1.4`
2. 打开“软件发布”,创建本次发布,通道仍选择 `stable`
3. 打开“发布包”,先点击要更新的软件卡片,进入该软件的发布包视图。
4. 点击“新建组件更新包”。进入某个软件后,包类型固定为“组件更新”,产品固定为当前软件。
5. 发布选择刚创建的 `1.1.4` 发布。
6. 平台选择和 current repository 完全一致的 `windows / x86_64 / msvc`
7. 文件名填写 `SimCAE-IFW-ComponentUpdate-com.simcae.dap-1.1.4-windows_x86_64-msvc.zip`
8. 保存后点击“上传”。
9. 选择上一步生成的 `$ComponentUpdateZip`
10. 上传成功后,发布包状态应变为“可用”。
上传成功后,服务端会把更新组件合并进 current repository,未变化组件保持不变。
### 11.6 上传后确认合并结果
重新读取服务器仓库:
```powershell
$Base = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc"
(Invoke-WebRequest "$Base/Updates.xml" -UseBasicParsing).Content
```
预期:
1. 更新过的组件版本变成新版本,例如 `com.simcae.dap``1.1.4`
2. 未更新的组件仍然存在,例如 `com.simcae.app` 还在。
3. 新增组件能出现在 `Updates.xml` 中。
4. 旧版本仓库仍保存在服务端 `releases/<版本>` 目录中,current 指向最新合并结果。
### 11.7 常见失败提示
| 提示含义 | 原因 | 处理 |
| --- | --- | --- |
| 当前产品、通道和平台下还没有可合并的 IFW 当前仓库 | 还没上传过完整交付包 | 先上传完整 Qt IFW 交付包 |
| 组件版本不能倒退或重复 | 上传组件版本小于或等于服务器 current 版本 | 升高组件 `package.xml` 里的版本后重新生成 |
| 组件依赖不存在 | 新组件依赖的组件不在 current 仓库,也不在本次包里 | 先发布依赖组件,或把依赖组件一起打进本次更新包 |
| 缺少组件目录 | `Updates.xml` 声明了组件,但 ZIP 里没有对应目录 | 重新用 `build-ifw-repository.ps1` 生成 |
| 包含未在 `Updates.xml` 声明的组件目录 | ZIP 里多了未声明目录 | 删除多余目录后重新压包 |
| 未包含 `Updates.xml` | 上传的不是 repository ZIP,或服务端没有配置 `SIMCAE_IFW_REPOGEN_PATH` 来从 packages 自动生成 | 上传 repository 形态 ZIP |
更新包失败时,服务端不会破坏原 current repository。
## 十二、换源
服务器地址变化时有两种处理方式:
| 方式 | 适用场景 |
| --- | --- |
| 临时换源命令 | 单台客户机器临时切到新仓库 |
| RepositoryUpdate 批量换源 | 已安装客户软件批量迁移仓库地址 |
换源只影响 `maintenancetool.exe` 访问 IFW repository。Launcher / Updater 的 API 地址来自服务端注入的 `app_config.json`,需要通过新发布包或重新安装包更新。
### 12.1 临时换源
临时换源适合开发、测试、临时排查。它不会永久改安装包里的默认源。
在 SIMCAE 项目根目录执行,假设客户软件安装在 `.tmp\maintenance-installed\SimCAE`
```powershell
$Install = ".\.tmp\maintenance-installed\SimCAE"
$Repo = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/"
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\switch-maintenance-repository.ps1" `
-MaintenanceToolPath "$Install\maintenancetool.exe" `
-RepositoryUrl $Repo `
-Mode Temp `
-Command check-updates `
-ClearCache
```
注意 `$Repo` 必须是仓库根地址,不能写到 `Updates.xml`
- 正确:`http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/`
- 错误:`http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/Updates.xml`
### 12.2 批量换源
批量换源适合服务器域名或 IP 变更。做法是在下一次 repository 的 `Updates.xml` 里加入 `RepositoryUpdate`,让 MaintenanceTool 更新组件时顺便替换本机源地址。
示例:把旧源 `http://192.168.1.158:18000/...` 替换为新源 `https://download.simcae.example.com/...`
```powershell
$UpdatesXml = "$Build\ifw-repository\Updates.xml"
$OldRepo = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/"
$NewRepo = "https://download.simcae.example.com/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/"
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\write-repository-update.ps1" `
-UpdatesXml $UpdatesXml `
-Action replace `
-OldUrl $OldRepo `
-NewUrl $NewRepo `
-DisplayName "SimCAE stable component repository" `
-ClearExisting
```
写入后重新压 repository 或重新组装 Qt IFW 交付包,再上传到 SimCAE Hub。客户下一次通过 MaintenanceTool 检查或更新组件后,会把仓库地址换成新地址。
## 十三、常见问题
| 现象 | 原因和处理 |
| --- | --- |
| 上传提示缺少 `Updates.xml` | 选择的不是 repository 或交付包结构不对 |
| MaintenanceTool 看不到更新 | 服务器仓库版本没有高于本机 `components.xml` 里的版本 |
| 客户下载不到安装器 | 发布包不是 Qt IFW 交付包,或客户安装器生成/登记失败 |
| Launcher 启动主程序失败 | `SIMCAE_LAUNCH_TOKEN` 和业务主程序编译时 token 不一致 |
| 直接双击 `SimCAE.exe` 被拦截 | 这是启用 Launcher 启动门禁后的预期行为 |
| 可选组件删除后 Updater 报缺文件 | Manifest 中可选组件文件没有标为可选,或组件边界划分不对 |
+208 -149
View File
@@ -15,8 +15,11 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <QApplication> #include <QApplication>
#include <QUrl>
#include <QUrlQuery>
#include <algorithm> #include <algorithm>
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/pem.h> #include <openssl/pem.h>
@@ -25,15 +28,44 @@
#include <openssl/err.h> #include <openssl/err.h>
#endif #endif
UpdaterLogic::UpdaterLogic(QObject* parent) namespace {
: QObject(parent)
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) 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")); const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) { if (detail.isObject()) {
const QJsonObject obj = detail.toObject(); const QJsonObject obj = detail.toObject();
@@ -44,32 +76,94 @@ QString serverDetailMessage(const QJsonObject& response)
} }
return detail.toString(); 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。 // Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。 // Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
m_error.clear(); m_error.clear();
QString url = m_serverAddr + "/api/v1/update/manifest"; Q_UNUSED(versionId);
QJsonObject body; m_manifestSha256.clear();
body["app_id"] = appId; m_manifestSignature.clear();
body["channel"] = channel; m_manifestSignatureAlg.clear();
body["version"] = targetVer; m_manifestKeyId.clear();
body["version_id"] = versionId; m_manifestSigned = false;
m_fileItems.clear();
m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp) 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; qDebug() << "Manifest API returned code:" << code;
m_manifest = QJsonObject(); m_manifest = QJsonObject();
m_manifestText.clear(); m_manifestText.clear();
m_manifestSha256.clear();
m_manifestSignature.clear();
m_manifestSignatureAlg.clear();
m_manifestKeyId.clear();
m_manifestSigned = false;
if (code == 200) if (code == 200)
{ {
if (resp.contains("manifest_text") && resp.contains("manifest")) 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 = resp["manifest_text"].toString(); m_manifestText = manifestText;
m_manifest = resp["manifest"].toObject(); 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(); qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
m_fileItems.clear(); m_fileItems.clear();
QJsonArray files = m_manifest.value("files").toArray(); QJsonArray files = m_manifest.value("files").toArray();
@@ -77,10 +171,11 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
{ {
QJsonObject fileObj = fileItem.toObject(); QJsonObject fileObj = fileItem.toObject();
FileDownloadItem fi; FileDownloadItem fi;
fi.path = fileObj.value("path").toString(); fi.path = fileObj.value(QStringLiteral("path")).toString();
fi.sha256 = fileObj.value("sha256").toString(); fi.sha256 = fileObj.value(QStringLiteral("sha256")).toString();
fi.size = fileObj.value("size").toVariant().toLongLong(); fi.size = manifestFileSize(fileObj);
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL fi.url = absoluteDownloadUrl(m_serverAddr,
fileObj.value(QStringLiteral("downloadUrl")).toString());
m_fileItems.append(fi); m_fileItems.append(fi);
} }
} }
@@ -88,8 +183,8 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
{ {
qDebug() << "Manifest response missing fields"; qDebug() << "Manifest response missing fields";
m_error = QCoreApplication::translate("UpdaterLogic", m_error = QCoreApplication::translate("UpdaterLogic",
"Target version manifest response is incomplete. Stage: download target manifest. App: %1, channel: %2, version: %3, version id: %4.") "Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4.")
.arg(appId, channel, targetVer, QString::number(versionId)); .arg(appId, channel, targetVer, releaseId);
} }
} }
else else
@@ -97,8 +192,8 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
qDebug() << "Failed to get manifest"; qDebug() << "Failed to get manifest";
const QString detail = serverDetailMessage(resp); const QString detail = serverDetailMessage(resp);
m_error = QCoreApplication::translate("UpdaterLogic", 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") "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, QString::number(versionId), .arg(QString::number(code), appId, channel, targetVer, releaseId,
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail)); detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
} }
emit fetchUrlFinished(); emit fetchUrlFinished();
@@ -197,14 +292,31 @@ 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."); "No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
return false; return false;
} }
QString signature = m_manifest.value("signature").toString(); if (!m_manifestSha256.isEmpty()) {
if (signature.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)
{ {
if (requireSignature) {
qDebug() << "Manifest signature empty"; qDebug() << "Manifest signature empty";
m_error = QCoreApplication::translate("UpdaterLogic", m_error = QCoreApplication::translate("UpdaterLogic",
"The manifest does not contain a signature. Stage: manifest signature verification."); "The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification.");
return false; return false;
} }
qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled.";
m_error.clear();
return true;
}
QString path = publicKeyPath; QString path = publicKeyPath;
if (!QFile::exists(path)) if (!QFile::exists(path))
@@ -244,7 +356,13 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
QJsonObject wrapper; QJsonObject wrapper;
wrapper["manifest"] = m_manifest; wrapper["manifest"] = m_manifest;
wrapper["manifestText"] = m_manifestText;
wrapper["manifest_text"] = 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); QJsonDocument doc(wrapper);
file.write(doc.toJson(QJsonDocument::Indented)); file.write(doc.toJson(QJsonDocument::Indented));
@@ -277,7 +395,8 @@ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& ver
} }
QJsonObject wrapper = doc.object(); 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", m_error = QCoreApplication::translate("UpdaterLogic",
"Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.") "Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath); .arg(version, filePath);
@@ -285,7 +404,18 @@ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& ver
} }
m_manifest = wrapper["manifest"].toObject(); 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; qDebug() << "Loaded cached manifest" << version;
m_error.clear(); m_error.clear();
return true; return true;
@@ -339,38 +469,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded()); if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
} }
QSet<QString> protectedPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"), QStringLiteral("launcher.exe"),
QStringLiteral("updater"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
for (const QString& path : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + path);
}
QStringList obsolete; QStringList obsolete;
QSet<QString> seen; QSet<QString> seen;
for (const QJsonValue& value : oldManifest.value("files").toArray()) { for (const QJsonValue& value : oldManifest.value("files").toArray()) {
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); 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(); const QString folded = path.toCaseFolded();
if (!isSafeRelativePath(path) || protectedPaths.contains(folded) if (!isSafeRelativePath(path) || isRuntimeProtectedPath(path)
|| newPaths.contains(folded) || seen.contains(folded)) || newPaths.contains(folded) || seen.contains(folded))
continue; continue;
seen.insert(folded); seen.insert(folded);
@@ -423,6 +532,15 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
.arg(stage, version, path, fullPath); .arg(stage, version, path, fullPath);
return false; 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); const QString actualSha = calcLocalFileSha256(fullPath);
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
{ {
@@ -464,7 +582,14 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; } if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; }
manifest.insert("signature", wrapper.value("manifest_signature").toString()); manifest.insert("signature", wrapper.value("manifest_signature").toString());
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); 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; } 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; } if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid"); return false; }
const qint64 payloadStart = 16 + qint64(headerSize); const qint64 payloadStart = 16 + qint64(headerSize);
const QJsonArray entries = packageMeta.value("files").toArray(); const QJsonArray entries = packageMeta.value("files").toArray();
@@ -473,6 +598,7 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); 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; } 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); FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
if (isRuntimeProtectedPath(path)) continue;
if (stagingDir.isEmpty()) 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; } 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; } 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) 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(); m_error.clear();
QString url = m_serverAddr + "/api/v1/update/download-url"; if (m_fileItems.isEmpty()) {
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", 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") "The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads.");
.arg(QString::number(code), appId, channel, targetVer, QString::number(versionId), } else {
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail)); qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest.";
} }
emit fetchUrlFinished(); emit fetchUrlFinished();
});
} }
@@ -609,8 +705,11 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
QNetworkAccessManager manager; QNetworkAccessManager manager;
manager.setProxy(QNetworkProxy::NoProxy); manager.setProxy(QNetworkProxy::NoProxy);
QNetworkRequest request(url); QNetworkRequest request{QUrl(url)};
request.setTransferTimeout(60000); request.setTransferTimeout(60000);
const QString clientToken = configValue(QStringLiteral("client_token"));
if (!clientToken.isEmpty())
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
if (existingSize > 0) if (existingSize > 0)
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-"); request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
@@ -712,38 +811,13 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{ {
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); return UpdatePathPolicy::isFullUpdateProtectedPath(
QSet<QString> protectedPaths{ path, ConfigHelper::instance().runtimeRelativePath());
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 bool UpdaterLogic::isSafeRelativePath(const QString& path) const
{ {
const QString normalized = QDir::fromNativeSeparators(path); return UpdatePathPolicy::isSafeRelativePath(path);
const QString clean = QDir::cleanPath(normalized);
return !clean.isEmpty()
&& !QDir::isAbsolutePath(clean)
&& clean != ".."
&& !clean.startsWith("../")
&& !clean.contains(":");
} }
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir, qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
@@ -882,13 +956,11 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
const QString& version, bool success) const QString& version, bool success)
{ {
QJsonArray files; Q_UNUSED(appId);
for (const FileDownloadItem& item : m_fileItems) Q_UNUSED(channel);
files.append(QJsonObject{{"path", item.path}, {"size", item.size}}); Q_UNUSED(version);
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version}, Q_UNUSED(success);
{"result", success ? "success" : "fail"}, {"files", files}}; qDebug() << "Download result report is not part of the current SimCAE Hub API; skipped.";
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, void UpdaterLogic::reportResult(const QString& deviceId,
@@ -896,24 +968,11 @@ void UpdaterLogic::reportResult(const QString& deviceId,
const QString& toVer, const QString& toVer,
bool success) bool success)
{ {
QString url = m_serverAddr + "/api/v1/update/report"; Q_UNUSED(deviceId);
Q_UNUSED(fromVer);
QJsonObject body; Q_UNUSED(toVer);
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id"); Q_UNUSED(success);
body["device_id"] = deviceId; qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
body["from_version"] = fromVer;
body["to_version"] = toVer;
if (success)
body["result"] = "success";
else
body["result"] = "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
Q_UNUSED(resp);
qDebug() << "Update result report returned code:" << code;
});
} }
QList<FileDownloadItem> UpdaterLogic::getFileList() const QList<FileDownloadItem> UpdaterLogic::getFileList() const
+7 -1
View File
@@ -24,7 +24,8 @@ class UpdaterLogic : public QObject
public: public:
explicit UpdaterLogic(QObject* parent = nullptr); 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 verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const; bool saveManifestCache(const QString& cacheDir) const;
@@ -63,6 +64,11 @@ private:
QString m_serverAddr; QString m_serverAddr;
QJsonObject m_manifest; QJsonObject m_manifest;
QString m_manifestText; QString m_manifestText;
QString m_manifestSha256;
QString m_manifestSignature;
QString m_manifestSignatureAlg;
QString m_manifestKeyId;
bool m_manifestSigned = false;
QList<FileDownloadItem> m_fileItems; QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false; bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0; qint64 m_downloadTotalBytes = 0;
+12 -3
View File
@@ -24,7 +24,7 @@
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater"); QApplication::setApplicationName("SimCAE Updater");
QTranslator translator; QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm")) if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator); app.installTranslator(&translator);
@@ -38,6 +38,7 @@ int main(int argc, char* argv[])
QString appId; QString appId;
QString channel; QString channel;
QString targetVersion; QString targetVersion;
QString releaseId;
int targetVersionId = 0; int targetVersionId = 0;
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
@@ -66,6 +67,8 @@ int main(int argc, char* argv[])
const QString arg = argv[i]; const QString arg = argv[i];
if (arg.startsWith("--bootstrap-resume=")) if (arg.startsWith("--bootstrap-resume="))
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); 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 bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath(); const QString runtimeDir = QApplication::applicationDirPath();
@@ -74,7 +77,13 @@ int main(int argc, char* argv[])
const QString targetDir = config.installRoot(); const QString targetDir = config.installRoot();
const QString updateDir = config.updateRoot(); const QString updateDir = config.updateRoot();
QDir().mkpath(updateDir); 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, QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Offline Package Not Applicable"), QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration.")); 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."), withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
logic.errorString())); logic.errorString()));
} else if (offlinePackagePath.isEmpty()) { } else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId);
} }
if (!logic.verifyManifestSignature()) { if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
-21
View File
@@ -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"
}
-21
View File
@@ -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"
}
-3
View File
@@ -1,3 +0,0 @@
{
"api_base_url": "http://192.168.1.158:8000"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"api_base_url": "http://192.168.229.128:8000" "api_base_url": "http://192.168.1.158:18000"
} }
Binary file not shown.
File diff suppressed because it is too large Load Diff
+16 -43
View File
@@ -1,59 +1,32 @@
客户端脚本说明 SimCAE Hub 客户端脚本说明
============== ==========================
本目录保存 update-client 的辅助脚本。项目根目录只保留源码、CMake 入口、Docs 和配置模板,脚本统一放在这里。 本目录保存 Qt/C++ 客户端更新链路的辅助脚本。客户端仍然由
Launcher、Updater、Bootstrap 和业务主程序组成,服务端接口使用当前
SimCAE Hub 的 Go API。
脚本列表: 脚本列表:
1. package-sdk.ps1 1. package-sdk.ps1
在 Windows 上生成给其他软件接入用的 UpdateClientSDK 包。 在 Windows 上生成给业务软件接入用的客户端更新运行时包。
注意:SDK 包只面向运行接入,不包含 config/server_config.json 和 config/server_config.qrc。
服务端地址必须在打包前写入源码目录 config/server_config.json,并重新编译进 Launcher/Updater。
2. package-client.ps1 2. package-client.ps1
Windows 上生成某个具体产品的最终客户端发布包 Windows 本地调试用的客户包脚本,需要手工提供 app_config.json
新流程建议上传完整软件 ZIP 到 SimCAE Hub,由服务端生成最终配置。
3. install-sdk.ps1 3. install-sdk.ps1
将 SDK 运行时复制到业务软件 Release 目录。 把客户端更新运行时复制到业务软件 Release 目录。
4. package-sdk.sh 4. package-sdk.sh
在 Linux 上生成给其他软件接入用的 UpdateClientSDK 包,输出 tar.gz。 在 Linux 上生成客户端更新运行时包,输出 tar.gz。
5. package-client.sh 5. package-client.sh
Linux 上生成某个具体产品的最终客户端发布包,输出 tar.gz Linux 本地调试用的客户包脚本,需要手工提供 app_config.json
推荐在 update-client 根目录执行 SDK 打包命令、两种打包模式、参数含义和输出位置,统一看
```powershell ../updater打包成SDK.md
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
```powershell 生成 SDK 后,SDK 根目录里只带给 SIMCAE 发布人员看的 SIMCAE打包上传.md。
.\scripts\package-client.ps1 ` 后续如何把 Launcher、Updater、Bootstrap 和必要运行库放进业务软件、如何
-SourceDir .\out\bin\Release ` 组装 Qt IFW 交付包并上传,以该文档为准。
-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
```
+2 -16
View File
@@ -4,8 +4,6 @@ param(
[string]$ReleaseDir = (Get-Location).Path, [string]$ReleaseDir = (Get-Location).Path,
[switch]$OverwriteConfig,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
@@ -15,11 +13,8 @@ $sdk = (Resolve-Path $SdkRoot).Path
$release = (Resolve-Path $ReleaseDir).Path $release = (Resolve-Path $ReleaseDir).Path
$binDir = Join-Path $sdk "bin" $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)) { foreach ($path in @($binDir)) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
throw "SDK file is missing: $path" throw "SDK file is missing: $path"
} }
@@ -61,14 +56,5 @@ Get-ChildItem $binDir -Force | Where-Object {
$targetConfigDir = Join-Path $release "config" $targetConfigDir = Join-Path $release "config"
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null 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 "SDK files installed to: $release"
Write-Host "Next: edit config/app_config.json, then start Launcher.exe." 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."
+12 -10
View File
@@ -3,7 +3,8 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ConfigFile, [string]$ConfigFile,
[string]$OutputDir = "", [string]$OutputDir = "",
[string]$ZipFile = "" [string]$ZipFile = "",
[switch]$SkipManifestCheck
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -13,10 +14,10 @@ if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin/Release" $SourceDir = Join-Path $RepoRoot "out/bin/Release"
} }
if ([string]::IsNullOrWhiteSpace($OutputDir)) { if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClient" $OutputDir = Join-Path $RepoRoot "dist/SimCAEUpdateClient"
} }
if ([string]::IsNullOrWhiteSpace($ZipFile)) { if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClient.zip" $ZipFile = Join-Path $RepoRoot "dist/SimCAEUpdateClient.zip"
} }
$source = (Resolve-Path $SourceDir).Path $source = (Resolve-Path $SourceDir).Path
@@ -64,12 +65,11 @@ function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestNam
$localData = [Environment]::GetFolderPath("LocalApplicationData") $localData = [Environment]::GetFolderPath("LocalApplicationData")
if ([string]::IsNullOrWhiteSpace($localData)) { return "" } if ([string]::IsNullOrWhiteSpace($localData)) { return "" }
$installId = Get-InstallDirectoryId $RuntimeDir $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 = @( $requiredFields = @(
"app_id", "channel", "current_version", "product_code", "channel", "current_version", "launch_token",
"client_token", "launch_token", "license_key",
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
) )
foreach ($field in $requiredFields) { foreach ($field in $requiredFields) {
@@ -127,9 +127,9 @@ $manifestCandidates = @(
(Join-Path $source "update/manifest_cache/$manifestName") (Join-Path $source "update/manifest_cache/$manifestName")
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 $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 $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) { if (Test-Path $OutputDir) {
@@ -161,8 +161,10 @@ Copy-Item $config $outputConfigPath -Force
} }
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) $manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
New-Item $manifestDir -ItemType Directory -Force | Out-Null if ($sourceManifest -and (Test-Path $sourceManifest)) {
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force New-Item $manifestDir -ItemType Directory -Force | Out-Null
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
}
$zipParent = Split-Path $ZipFile -Parent $zipParent = Split-Path $ZipFile -Parent
New-Item $zipParent -ItemType Directory -Force | Out-Null New-Item $zipParent -ItemType Directory -Force | Out-Null
+8 -8
View File
@@ -6,8 +6,8 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_DIR="$REPO_ROOT/out/linux/bin" SOURCE_DIR="$REPO_ROOT/out/linux/bin"
CONFIG_FILE="" CONFIG_FILE=""
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClient-linux" OUTPUT_DIR="$REPO_ROOT/dist/SimCAEUpdateClient-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClient-linux.tar.gz" ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEUpdateClient-linux.tar.gz"
SKIP_MANIFEST_CHECK=0 SKIP_MANIFEST_CHECK=0
usage() { usage() {
@@ -17,8 +17,8 @@ Usage: package-client.sh --config-file FILE [options]
Options: Options:
--source-dir DIR Release/install root to package. Default: ./out/linux/bin --source-dir DIR Release/install root to package. Default: ./out/linux/bin
--config-file FILE app_config.json used by this client package. Required. --config-file FILE app_config.json used by this client package. Required.
--output-dir DIR Output directory. Default: ./dist/UpdateClient-linux --output-dir DIR Output directory. Default: ./dist/SimCAEUpdateClient-linux
--archive FILE Output tar.gz. Default: ./dist/UpdateClient-linux.tar.gz --archive FILE Output tar.gz. Default: ./dist/SimCAEUpdateClient-linux.tar.gz
--skip-manifest-check Skip current-version manifest cache check. --skip-manifest-check Skip current-version manifest cache check.
-h, --help Show this help. -h, --help Show this help.
EOF EOF
@@ -89,7 +89,7 @@ user_data_manifest_candidate() {
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}" local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local install_id local install_id
install_id="$(install_directory_id "$runtime_dir")" 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" "$data_home" "$install_id" "$manifest_name"
} }
@@ -116,7 +116,7 @@ CONFIG_FILE="$(realpath "$CONFIG_FILE")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")" OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")" 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 if [[ -z "$(json_value "$field")" ]]; then
echo "Config file is missing required field: $field" >&2 echo "Config file is missing required field: $field" >&2
exit 1 exit 1
@@ -187,8 +187,8 @@ for candidate in "${MANIFEST_CANDIDATES[@]}"; do
fi fi
done done
if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then
echo "Missing signed Manifest cache for current version: $MANIFEST_NAME." >&2 echo "Missing Manifest cache for current version: $MANIFEST_NAME." >&2
echo "Complete online update/verification for this version before packaging. Searched paths:" >&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 printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2
exit 1 exit 1
fi fi
+23 -39
View File
@@ -3,7 +3,6 @@ param(
[string]$OutputDir = "", [string]$OutputDir = "",
[string]$ZipFile = "", [string]$ZipFile = "",
[string]$SdkVersion = "0.1.0", [string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "",
[switch]$IncludeDemoMainApp, [switch]$IncludeDemoMainApp,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
@@ -11,21 +10,18 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot $RepoRoot = Split-Path -Parent $PSScriptRoot
$DefaultSdkName = if ($IncludeQtRuntime) { "UpdateClientSDK-With-QtDll" } else { "UpdateClientSDK" }
if ([string]::IsNullOrWhiteSpace($SourceDir)) { if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin/Release" $SourceDir = Join-Path $RepoRoot "out/bin/Release"
} }
if ([string]::IsNullOrWhiteSpace($OutputDir)) { if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClientSDK" $OutputDir = Join-Path $RepoRoot "dist/$DefaultSdkName"
} }
if ([string]::IsNullOrWhiteSpace($ZipFile)) { if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClientSDK.zip" $ZipFile = Join-Path $RepoRoot "dist/$DefaultSdkName.zip"
}
if ([string]::IsNullOrWhiteSpace($ExampleConfig)) {
$ExampleConfig = Join-Path $RepoRoot "config/app_config.example.json"
} }
$source = (Resolve-Path $SourceDir).Path $source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") $requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
foreach ($name in $requiredFiles) { foreach ($name in $requiredFiles) {
@@ -35,16 +31,6 @@ foreach ($name in $requiredFiles) {
} }
} }
$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 { $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
$_.Extension -in @('.pdb', '.ilk') $_.Extension -in @('.pdb', '.ilk')
@@ -60,8 +46,7 @@ $binDir = Join-Path $OutputDir "bin"
$configDir = Join-Path $OutputDir "config" $configDir = Join-Path $OutputDir "config"
$scriptsDir = Join-Path $OutputDir "scripts" $scriptsDir = Join-Path $OutputDir "scripts"
$commonDir = Join-Path $OutputDir "Common" $commonDir = Join-Path $OutputDir "Common"
$docsDir = Join-Path $OutputDir "Docs" New-Item $binDir,$configDir,$scriptsDir,$commonDir -ItemType Directory -Force | Out-Null
New-Item $binDir,$configDir,$scriptsDir,$commonDir,$docsDir -ItemType Directory -Force | Out-Null
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem") $excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" } if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
@@ -86,6 +71,11 @@ function Test-IsQtRuntimeFile {
return ( return (
$Item.Name -match '^Qt5.*\.dll$' -or $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 @( $Item.Name -in @(
"libEGL.dll", "libEGL.dll",
"libGLESv2.dll", "libGLESv2.dll",
@@ -99,15 +89,16 @@ Get-ChildItem $source -Force | Where-Object {
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_) $_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
} | Copy-Item -Destination $binDir -Recurse -Force } | 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
$commonSourceDir = Join-Path $RepoRoot "Common" $commonSourceDir = Join-Path $RepoRoot "Common"
$commonSourceFiles = @( $commonSourceFiles = @(
"ConfigHelper.h", "ConfigHelper.h",
"ConfigHelper.cpp", "ConfigHelper.cpp",
"IntegrityHelper.h",
"IntegrityHelper.cpp",
"TicketHelper.h", "TicketHelper.h",
"TicketHelper.cpp" "TicketHelper.cpp",
"UpdatePathPolicy.h",
"UpdatePathPolicy.cpp"
) )
foreach ($commonFile in $commonSourceFiles) { foreach ($commonFile in $commonSourceFiles) {
$commonPath = Join-Path $commonSourceDir $commonFile $commonPath = Join-Path $commonSourceDir $commonFile
@@ -117,21 +108,12 @@ foreach ($commonFile in $commonSourceFiles) {
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
} }
$docsSourceDir = Join-Path $RepoRoot "Docs" $sdkGuideName = [string]::Concat("SIMCAE", [char]0x6253, [char]0x5305, [char]0x4e0a, [char]0x4f20, ".md")
if (Test-Path $docsSourceDir) { $sdkGuideSource = Join-Path $RepoRoot $sdkGuideName
Copy-Item (Join-Path $docsSourceDir "*") $docsDir -Recurse -Force if (Test-Path $sdkGuideSource) {
} Copy-Item $sdkGuideSource (Join-Path $OutputDir $sdkGuideName) -Force
$wordGuideSource = @($RepoRoot, $docsSourceDir) |
Where-Object { Test-Path $_ } |
ForEach-Object { Get-ChildItem $_ -File -Filter "*.docx" } |
Where-Object { $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" } |
Sort-Object Name |
Select-Object -First 1
if ($wordGuideSource) {
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
} else { } else {
Write-Warning "SDK Word guide is missing. Continue packaging with Markdown documents in Docs/." throw "SIMCAE packaging guide is missing: $sdkGuideSource"
} }
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force
@@ -144,8 +126,10 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in
sdk_type = "external-updater-runtime" sdk_type = "external-updater-runtime"
required_entry = "Launcher.exe" required_entry = "Launcher.exe"
contains_demo_main_app = [bool]$IncludeDemoMainApp contains_demo_main_app = [bool]$IncludeDemoMainApp
docs_entry = "Docs/01-客户端接入打包部署指南.md" contains_qt_runtime = [bool]$IncludeQtRuntime
word_guide_included = [bool]$wordGuideSource contains_final_config = $false
docs_entry = $sdkGuideName
word_guide_included = $false
integration_sources = $commonSourceFiles integration_sources = $commonSourceFiles
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8 } | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
+38 -37
View File
@@ -8,8 +8,8 @@ SOURCE_DIR="$REPO_ROOT/out/linux/bin"
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux" OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz" ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz"
SDK_VERSION="0.1.0" SDK_VERSION="0.1.0"
EXAMPLE_CONFIG="$REPO_ROOT/config/app_config.linux.example.json"
INCLUDE_DEMO_MAIN_APP=0 INCLUDE_DEMO_MAIN_APP=0
INCLUDE_QT_RUNTIME=0
usage() { usage() {
cat <<'EOF' cat <<'EOF'
@@ -20,8 +20,8 @@ Options:
--output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux --output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux
--archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz --archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz
--sdk-version VERSION SDK version. Default: 0.1.0 --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-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. -h, --help Show this help.
EOF EOF
} }
@@ -32,15 +32,14 @@ while [[ $# -gt 0 ]]; do
--output-dir) OUTPUT_DIR="$2"; shift 2 ;; --output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;; --archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--sdk-version) SDK_VERSION="$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-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
--include-qt-runtime) INCLUDE_QT_RUNTIME=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac esac
done done
SOURCE_DIR="$(realpath "$SOURCE_DIR")" SOURCE_DIR="$(realpath "$SOURCE_DIR")"
EXAMPLE_CONFIG="$(realpath "$EXAMPLE_CONFIG")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")" OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")" ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
@@ -51,21 +50,6 @@ for name in Launcher Updater Bootstrap; do
fi fi
done 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)" DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2 echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2
@@ -74,7 +58,22 @@ if [[ -n "$DEBUG_ARTIFACT" ]]; then
fi fi
rm -rf "$OUTPUT_DIR" rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common" "$OUTPUT_DIR/Docs" mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common"
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 shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do for item in "$SOURCE_DIR"/*; do
@@ -86,16 +85,16 @@ for item in "$SOURCE_DIR"/*; do
fi fi
;; ;;
*) *)
if [[ "$INCLUDE_QT_RUNTIME" -eq 0 ]] && is_qt_runtime_item "$base"; then
continue
fi
cp -a "$item" "$OUTPUT_DIR/bin/" cp -a "$item" "$OUTPUT_DIR/bin/"
;; ;;
esac esac
done done
shopt -u dotglob nullglob shopt -u dotglob nullglob
cp "$EXAMPLE_CONFIG" "$OUTPUT_DIR/config/app_config.json" for common_file in ConfigHelper.h ConfigHelper.cpp IntegrityHelper.h IntegrityHelper.cpp TicketHelper.h TicketHelper.cpp UpdatePathPolicy.h UpdatePathPolicy.cpp; do
cp "$PUBLIC_KEY" "$OUTPUT_DIR/config/manifest_public_key.pem"
for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.cpp; do
common_path="$REPO_ROOT/Common/$common_file" common_path="$REPO_ROOT/Common/$common_file"
if [[ ! -f "$common_path" ]]; then if [[ ! -f "$common_path" ]]; then
echo "SDK Common integration source is missing: $common_path" >&2 echo "SDK Common integration source is missing: $common_path" >&2
@@ -104,17 +103,13 @@ for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.c
cp "$common_path" "$OUTPUT_DIR/Common/$common_file" cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
done done
if [[ -d "$REPO_ROOT/Docs" ]]; then SDK_GUIDE_NAME="SIMCAE打包上传.md"
cp -a "$REPO_ROOT/Docs/." "$OUTPUT_DIR/Docs/" SDK_GUIDE_SOURCE="$REPO_ROOT/$SDK_GUIDE_NAME"
fi if [[ -f "$SDK_GUIDE_SOURCE" ]]; then
cp "$SDK_GUIDE_SOURCE" "$OUTPUT_DIR/$SDK_GUIDE_NAME"
WORD_GUIDE="$(find "$REPO_ROOT" "$REPO_ROOT/Docs" -maxdepth 1 -type f -name '*SDK*.docx' ! -name '~$*' 2>/dev/null | sort | sed -n '1p')"
WORD_GUIDE_INCLUDED=false
if [[ -n "$WORD_GUIDE" ]]; then
cp "$WORD_GUIDE" "$OUTPUT_DIR/$(basename "$WORD_GUIDE")"
WORD_GUIDE_INCLUDED=true
else else
echo "Warning: SDK Word guide is missing. Continue packaging with Markdown documents in Docs/." >&2 echo "SIMCAE packaging guide is missing: $SDK_GUIDE_SOURCE" >&2
exit 1
fi fi
cp "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh" cp "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh"
@@ -134,13 +129,19 @@ cat > "$OUTPUT_DIR/sdk_manifest.json" <<EOF
"platform": "linux", "platform": "linux",
"required_entry": "Launcher", "required_entry": "Launcher",
"contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false), "contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false),
"docs_entry": "Docs/01-客户端接入打包部署指南.md", "contains_qt_runtime": $([[ "$INCLUDE_QT_RUNTIME" -eq 1 ]] && echo true || echo false),
"word_guide_included": $WORD_GUIDE_INCLUDED, "contains_final_config": false,
"docs_entry": "$SDK_GUIDE_NAME",
"word_guide_included": false,
"integration_sources": [ "integration_sources": [
"ConfigHelper.h", "ConfigHelper.h",
"ConfigHelper.cpp", "ConfigHelper.cpp",
"IntegrityHelper.h",
"IntegrityHelper.cpp",
"TicketHelper.h", "TicketHelper.h",
"TicketHelper.cpp" "TicketHelper.cpp",
"UpdatePathPolicy.h",
"UpdatePathPolicy.cpp"
] ]
} }
EOF EOF
+124
View File
@@ -0,0 +1,124 @@
# Updater 打包成 SDK
本文只说明如何从 `SIMCAE/update-client` 生成给 SIMCAE 打包流程使用的更新客户端 SDK。SIMCAE 如何拿这个 SDK 打客户安装器和交付包,见当前目录《SIMCAE打包上传.md》。
## 一、SDK 包含什么
SDK 用来把 Hub 更新客户端接入 SIMCAE 安装包。
| 内容 | 作用 |
| --- | --- |
| `Launcher.exe` | 客户日常启动入口,检查整包更新并启动主程序 |
| `Updater.exe` | 拉取 Manifest、下载发布包、校验 SHA-256、准备安装 |
| `Bootstrap.exe` | 替换运行中文件时接管安装 |
| Qt 运行库 | 可选,给没有单独 Qt 运行环境的接入方使用 |
| `SIMCAE打包上传.md` | 给 SIMCAE 发布人员看的打包、交付包组装和上传说明 |
SDK 不包含最终客户配置文件,例如 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`。这些文件由服务端在上传客户软件包或 Qt IFW 交付包时生成或注入。
打包后的 SDK 根目录只放 `SIMCAE打包上传.md` 这一份使用说明。本文是维护者打 SDK 的说明,不随 SDK 一起交给接入方。
## 二、编译 Release
先进入 SIMCAE 仓库下的 `update-client` 目录。如果当前已经在 SIMCAE 仓库根目录:
```powershell
cd .\update-client
```
然后执行:
```powershell
cmake --preset x64-release -DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
cmake --build --preset x64-release
```
如果 OpenSSL 安装在其他目录,只改 `SIMCAE_OPENSSL_ROOT` 这一项。
编译完成后,Release 产物通常位于 `out/bin/Release`
检查核心程序:
```powershell
Test-Path .\out\bin\Release\Launcher.exe
Test-Path .\out\bin\Release\Updater.exe
Test-Path .\out\bin\Release\Bootstrap.exe
```
预期都返回 `True`
## 三、打包不带 Qt 运行库的 SDK
适用于接入方已经有 Qt 运行环境,或希望自己控制 Qt DLL 的情况。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
输出:
| 输出 | 说明 |
| --- | --- |
| `dist\UpdateClientSDK` | 不带 Qt 运行库的 SDK 展开目录 |
| `dist\UpdateClientSDK.zip` | 不带 Qt 运行库的 SDK 压缩包 |
## 四、打包带 Qt 运行库的 SDK
适用于接入方不想单独准备 Qt DLL,或者希望拿到后能直接放进安装包。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK-With-QtDll `
-ZipFile .\dist\UpdateClientSDK-With-QtDll.zip `
-SdkVersion 0.1.0 `
-IncludeQtRuntime
```
这里的 `With-QtDll` 表示包里带的是运行所需的 Qt DLL,不是完整 Qt SDK。
输出:
| 输出 | 说明 |
| --- | --- |
| `dist\UpdateClientSDK-With-QtDll` | 带 Qt 运行库 DLL 的 SDK 展开目录 |
| `dist\UpdateClientSDK-With-QtDll.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
## 五、打包后检查
```powershell
Test-Path .\dist\UpdateClientSDK-With-QtDll\bin\Launcher.exe
Test-Path .\dist\UpdateClientSDK-With-QtDll\bin\Updater.exe
Test-Path .\dist\UpdateClientSDK-With-QtDll\bin\Bootstrap.exe
Test-Path .\dist\UpdateClientSDK-With-QtDll\SIMCAE打包上传.md
Test-Path .\dist\UpdateClientSDK-With-QtDll.zip
```
预期都返回 `True`
## 六、不要提交的内容
当前仓库的 `.gitignore` 已忽略这些本地内容:
- `thirdparty/`
- `out/`
- `dist/`
- `*.exe`
- `*.dll`
- `*.zip`
- `config/app_config.json`
- `config/client_identity.dat`
- `config/local_state.json`
- `config/version_policy.dat`
提交前看一下:
```powershell
git status --short
```
不要把本地依赖、编译产物、SDK ZIP、客户配置和运行状态提交进仓库。