Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 613607475e | |||
| 8dfef45bc4 |
@@ -29,6 +29,7 @@ pdf_requirements.txt
|
|||||||
*.obj
|
*.obj
|
||||||
*.o
|
*.o
|
||||||
*.pdb
|
*.pdb
|
||||||
|
*.qm
|
||||||
*.ilk
|
*.ilk
|
||||||
*.idb
|
*.idb
|
||||||
*.tlog
|
*.tlog
|
||||||
|
|||||||
+7
-11
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
+92
-31
@@ -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 (!verifySignature(manifestText, signature)) return false;
|
if (!manifestSha256.isEmpty()) {
|
||||||
|
const QString actualSha = QString::fromLatin1(
|
||||||
|
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
|
||||||
|
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
|
||||||
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||||
|
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
|
||||||
|
.arg(version, manifestSha256, actualSha);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (signedManifest && !signature.isEmpty()) {
|
||||||
|
if (!verifySignature(manifestText, signature)) return false;
|
||||||
|
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
|
||||||
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||||
|
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
|
||||||
|
.arg(version);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QJsonParseError manifestError;
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
+13
-71
@@ -1,81 +1,23 @@
|
|||||||
客户端文档入口
|
SimCAE Hub 客户端文档入口
|
||||||
==============
|
========================
|
||||||
|
|
||||||
你第一次打开 update-client/Docs 时,先看这一份。这里告诉你每份文档是干什么的,以及不同角色应该从哪里开始。
|
本目录记录 SimCAE Hub 的 Qt/C++ 客户端更新链路。当前方向是保留 Launcher / Updater / Bootstrap 的桌面客户端机制,适配 SimCAE Hub 当前 Go API,不用 Go 或 Web 技术重写客户端。
|
||||||
|
|
||||||
文档阅读顺序
|
建议先按这个顺序阅读:
|
||||||
============
|
|
||||||
|
|
||||||
1. 01-客户端接入打包部署指南.md
|
1. 01-客户端接入打包部署指南.md
|
||||||
适合 SDK 接入方、测试人员和交付人员。按“生成 SDK -> 放进业务软件 -> 生成配置 -> 联调 -> 打最终包”的顺序写。
|
说明客户端运行链路、配置字段、安装目录口径和接入限制。
|
||||||
|
|
||||||
2. 02-编译环境和第三方依赖说明.md
|
2. 02-编译环境和第三方依赖说明.md
|
||||||
适合需要编译 Launcher、Updater、Bootstrap 的人。说明 Windows/Linux 下 Qt、OpenSSL、thirdparty/ 和 CMake 怎么准备。
|
说明 Windows 和 Linux 下编译 Qt/C++ 客户端需要的工具、Qt、OpenSSL 和 CMake 命令。
|
||||||
|
|
||||||
3. ../i18n/ReadMe.txt
|
3. ../打包成SDK.md
|
||||||
适合维护界面文案的人。说明新增 tr() 后怎么更新 .ts、生成 .qm,并把翻译文件打进 qrc。
|
说明如何从 update-client 编译产物生成给 SIMCAE 开发者使用的 SDK 包。
|
||||||
|
|
||||||
常用任务入口
|
4. ../config/server_config.json
|
||||||
============
|
编译进客户端资源的服务端地址配置,当前测试服务器是 http://192.168.1.158:18000。
|
||||||
|
|
||||||
如果你只是拿到 SDK 接入业务软件:
|
5. ../scripts/ReadMe.txt
|
||||||
|
SDK 打包脚本和客户安装包打包脚本的简短说明。
|
||||||
|
|
||||||
```text
|
客户端能力、接口链路、配置字段和接入限制统一看 01 文档。当前不包含邮箱、支付、告警、灰度发布等页面上没有的业务模块;崩溃报告作为旧系统兼容后端接口保留,具体看项目根目录的 项目细节.md。
|
||||||
读 01-客户端接入打包部署指南.md 的“三、你:把 SDK 放进业务软件目录”和“四、你:生成并填写 app_config.json”。
|
|
||||||
```
|
|
||||||
|
|
||||||
如果你要重新打 Windows SDK 包:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd update-client
|
|
||||||
.\scripts\package-sdk.ps1 -SourceDir .\out\bin\Release -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
如果你要重新打 Linux SDK 包:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd update-client
|
|
||||||
cmake --preset linux-x64-release
|
|
||||||
cmake --build --preset linux-x64-release
|
|
||||||
bash ./scripts/package-sdk.sh --source-dir ./out/linux/bin --output-dir ./dist/UpdateClientSDK-linux --archive ./dist/UpdateClientSDK-linux.tar.gz --sdk-version 0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
客户端配置速记
|
|
||||||
==============
|
|
||||||
|
|
||||||
config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。后续运行配置优先从 QSettings 读取。
|
|
||||||
config/server_config.json 是编译期服务端地址配置源文件。它通过 config/server_config.qrc 编进 Launcher / Updater / MainApp,不写入 app_config.json,也不写入注册表。修改 api_base_url 后必须重新编译客户端程序才会生效。
|
|
||||||
如果 config/app_config.json 内容被修改,下一次启动时会按解析后的 JSON 内容 SHA256 判断变化并重新导入注册表。
|
|
||||||
非空 app_config.json 成功导入注册表后会自动清空为 {},文件保留不删除,方便下次直接粘贴管理后台生成的新配置。
|
|
||||||
Windows 注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。
|
|
||||||
Linux 配置位置由 Qt QSettings 决定,通常在当前用户 home 目录的 .config/Marsco/UpdateClientSDK.conf 一类路径下。
|
|
||||||
Windows 运行态数据目录类似:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\。
|
|
||||||
如果检测到 app_config.json 发生变化,SDK 会删除当前用户数据目录里的 client_identity.dat、version_policy.dat 和 local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
|
|
||||||
正常启动成功后不要删除这些状态文件,它们用于本地身份、离线策略和安全状态。
|
|
||||||
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
|
|
||||||
|
|
||||||
接入新软件时通常需要修改:
|
|
||||||
|
|
||||||
1. app_id、app_name、channel、current_version。
|
|
||||||
2. client_token、license_key、launch_token。
|
|
||||||
3. config/server_config.json 里的 api_base_url。
|
|
||||||
4. main_executable:团队业务主程序文件名。
|
|
||||||
5. launcher_executable、updater_executable、bootstrap_executable。
|
|
||||||
6. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
|
|
||||||
|
|
||||||
Windows 完整格式参考 update-client/config/app_config.example.json;Linux 完整格式参考 update-client/config/app_config.linux.example.json。
|
|
||||||
运行时生成的 client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。app_config.json 可以作为部署模板,但不要把某台机器运行后产生的临时状态混进去。
|
|
||||||
|
|
||||||
Windows 发布打包:
|
|
||||||
|
|
||||||
1. 使用 Release 配置编译全部客户端程序。
|
|
||||||
2. 先完成当前版本在线校验。签名 Manifest 缓存现在默认保存在当前用户数据目录:
|
|
||||||
Windows:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache
|
|
||||||
Linux:$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache,未设置 XDG_DATA_HOME 时通常是 ~/.local/share。
|
|
||||||
打包脚本会优先从用户数据目录读取,也兼容旧版 Release 目录里的 update/manifest_cache。
|
|
||||||
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名;确认客户端程序已用正确的 config/server_config.json 编译。
|
|
||||||
4. 在 PowerShell 执行:
|
|
||||||
powershell -ExecutionPolicy Bypass -File .\scripts\package-client.ps1 -ConfigFile .\config\app_config.json
|
|
||||||
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
|
|
||||||
|
|
||||||
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
|
|
||||||
|
|||||||
+152
-374
@@ -1,432 +1,210 @@
|
|||||||
# UpdateClientSDK 客户端接入、打包和部署指南
|
# SimCAE Hub 客户端接入、打包和部署指南
|
||||||
|
|
||||||
本文按“维护者打包 SDK -> 你接入业务软件 -> 联调测试 -> 生成最终客户端包”的顺序说明。你拿到这份文档后,按章节一步一步做即可。
|
本文说明 `update-client` 的当前实现。它保留 Launcher / Updater / Bootstrap 的桌面客户端机制,服务端协议使用 SimCAE Hub 当前 Go API。
|
||||||
|
|
||||||
## 先看这里:你要做哪件事
|
## 1. 适用范围
|
||||||
|
|
||||||
| 你的目标 | 直接看哪一节 |
|
当前客户端只覆盖项目已有页面和接口对应的能力:
|
||||||
|
|
||||||
|
1. 产品版本、软件发布、发布包和 Manifest。
|
||||||
|
2. 客户授权、在线命名用户席位和门户受控下载。
|
||||||
|
3. 在线检查更新、Manifest 拉取、受控下载、SHA-256 校验。
|
||||||
|
4. Manifest 签名验签、临时文件、断点重试、安装前后完整性校验。
|
||||||
|
|
||||||
|
邮箱、支付、灰度、告警等页面上没有的能力不属于当前范围。崩溃报告是 SimCAE Hub 保留的旧系统兼容后端接口,不属于 Launcher / Updater / Bootstrap 的更新链路;接入方需要崩溃上报时,按项目根目录 `项目细节.md` 里的崩溃报告接口说明调用。
|
||||||
|
|
||||||
|
## 2. 客户端程序组成
|
||||||
|
|
||||||
|
| 程序 | 作用 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 重新生成给别人用的 SDK 包 | 二、维护者:生成 SDK 包 |
|
| `Launcher` | 客户日常启动入口,负责导入配置、使用 `client_token` 检查更新、启动 Updater 或主程序 |
|
||||||
| 把 SDK 放到 SimCAE 或其他业务软件目录 | 三、你:把 SDK 放进业务软件目录 |
|
| `Updater` | 负责拉取 Manifest、下载发布包、校验文件、准备安装事务 |
|
||||||
| 从后台生成 `app_config.json` 和 qrc 服务端配置 | 四、你:生成并填写客户端配置 |
|
| `Bootstrap` | 负责在需要替换运行中文件时接管安装,并把结果交回 Updater |
|
||||||
| 给业务主程序接入启动保护代码 | 五、你:业务主程序接入要求 |
|
| `MainApp` | 示例主程序,用来验证 launch ticket 和安装后完整性校验 |
|
||||||
| 验证升级、回滚、健康检查 | 六、你:联调测试 |
|
| `Common` | 配置、HTTP、票据、完整性校验等公共代码 |
|
||||||
| 生成最终交付给用户的客户端包 | 七、维护者:生成最终客户端包 |
|
|
||||||
|
|
||||||
## 一、这个 SDK 是什么
|
## 3. 当前在线更新链路
|
||||||
|
|
||||||
UpdateClientSDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力做成一组独立程序,让业务软件通过这些程序完成检查更新、下载、安装、回滚和启动保护。
|
1. `Launcher` 启动后读取服务端生成的 `config/app_config.json`,并把静态配置导入当前用户的运行配置。
|
||||||
|
2. 如果配置里没有 `api_base_url`,客户端会回退到编译进 EXE 资源中的 `server_config.json`。
|
||||||
|
3. `Launcher` 确保存在 `device_id`,并检查 `client_token` 是否存在。
|
||||||
|
4. `Launcher` 调用 `GET /api/v1/client/update/authorized-check`,请求头带 `X-Client-Token`。
|
||||||
|
5. 如果服务端返回可用发布,`Launcher` 启动 `Updater`,并传入产品编码、渠道、目标版本和发布 ID。
|
||||||
|
6. `Updater` 调用 `GET /api/v1/client/update/manifest`,请求头继续带 `X-Client-Token`。
|
||||||
|
7. `Updater` 先校验服务端返回的 `manifestSha256`,再按配置决定是否强制要求 RSA-SHA256 签名。
|
||||||
|
8. `Updater` 从 Manifest 中读取每个文件的 `downloadUrl`、`sizeBytes` 和 `sha256`。
|
||||||
|
9. 下载请求统一带 `X-Client-Token`。
|
||||||
|
10. 下载使用 `.part` 临时文件保存进度,请求失败后按网络重试策略处理。
|
||||||
|
11. 文件下载完成后,客户端按 Manifest 校验文件大小和 SHA-256。
|
||||||
|
12. 安装前校验 staging 目录,安装完成后保存 Manifest 缓存,并可在主程序启动时再次校验已安装文件。
|
||||||
|
|
||||||
SDK 核心程序:
|
## 4. 关键配置字段
|
||||||
|
|
||||||
- `Launcher.exe` / `Launcher`:用户入口。检查版本、验证授权和策略,决定直接启动业务主程序或进入升级流程。
|
正式客户安装包里的 `config/app_config.json` 由服务端在上传发布包 ZIP 时自动生成。常用字段如下:
|
||||||
- `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 包
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `product_code` | SimCAE Hub 后台产品目录中的产品编码,例如 `stage2-dap` |
|
||||||
|
| `app_id` | 本地应用标识,默认和产品编码一致 |
|
||||||
|
| `channel` | 发布渠道,例如 `stable` |
|
||||||
|
| `current_version` | 当前本地安装版本,例如 `1.0.0` |
|
||||||
|
| `api_base_url` | 后端 API 地址,例如 `http://192.168.1.158:18000` |
|
||||||
|
| `client_token` | Launcher/Updater 调更新接口使用的部署级令牌,不绑定某一个客户 |
|
||||||
|
| `install_root` | 相对 Launcher/Updater 所在运行目录解析的更新根目录,决定 Updater、Bootstrap 和启动校验作用在哪棵目录 |
|
||||||
|
| `main_executable` | 相对运行目录解析的业务入口程序,通常是 `MainApp.exe` 或真实软件入口 |
|
||||||
|
| `launcher_executable` | 相对运行目录解析的 Launcher 文件名,主要用于提示和保持启动链路配置一致 |
|
||||||
|
| `updater_executable` | 相对运行目录解析的 Updater 文件名,Launcher 检查到更新后会启动它 |
|
||||||
|
| `bootstrap_executable` | 相对运行目录解析的 Bootstrap 文件名,Updater 需要替换文件时会启动它 |
|
||||||
|
| `platform` | 操作系统,例如 `windows` 或 `linux` |
|
||||||
|
| `arch` | 架构,例如 `x86_64` |
|
||||||
|
| `abi` | ABI,例如 `msvc`;没有时可留空 |
|
||||||
|
| `launch_token` | Launcher 和 MainApp 之间生成一次性启动票据的本地密钥 |
|
||||||
|
| `require_manifest_signature` | 是否强制要求 Manifest 必须带签名 |
|
||||||
|
| `verify_installed_on_start` | 主程序启动时是否按 Manifest 缓存校验已安装文件 |
|
||||||
|
|
||||||
这一节是 SDK 维护者操作。接入方通常只需要拿到 `UpdateClientSDK.zip`。
|
`config/server_config.json` 会编译进客户端资源,作为 `api_base_url` 缺失时的兜底地址,当前测试服务器地址为:
|
||||||
|
|
||||||
打包前确认:
|
`http://192.168.1.158:18000`
|
||||||
|
|
||||||
1. 已在 Windows 上用 Release 配置编译完成,输出目录里有 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe`。
|
如果换服务器,可以改完该文件后重新编译客户端;正式客户包通常由服务端写入 `api_base_url`,不需要把 `server_config.json` 暴露给客户。
|
||||||
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 中执行:
|
## 5. 编译和打包 SDK
|
||||||
|
|
||||||
```powershell
|
客户端编译、带 Qt 和不带 Qt 两种 SDK 打包方式、输出目录、输出 ZIP 文件名,统一维护在 [../打包成SDK.md](../打包成SDK.md)。
|
||||||
cd C:\Users\admin\Desktop\update-client
|
|
||||||
|
|
||||||
.\scripts\package-sdk.ps1 `
|
本文件只说明 SDK 在客户软件里的接入位置和运行逻辑,不重复维护打包命令。
|
||||||
-SourceDir .\out\bin\Release `
|
|
||||||
-OutputDir .\dist\UpdateClientSDK `
|
|
||||||
-ZipFile .\dist\UpdateClientSDK.zip `
|
|
||||||
-SdkVersion 0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
生成结果:
|
SDK 包不会包含最终 `app_config.json`、`server_config.json`、`server_config.qrc` 或 `manifest_public_key.pem`。这些最终配置在完整客户软件包或 Qt IFW 交付包上传到 SimCAE Hub 后由服务端生成。
|
||||||
|
|
||||||
|
## 6. 客户安装包配置
|
||||||
|
|
||||||
|
接入方应把以下文件放到客户软件目录的根目录或 `bin/` 目录:
|
||||||
|
|
||||||
|
1. `Launcher`
|
||||||
|
2. `Updater`
|
||||||
|
3. `Bootstrap`
|
||||||
|
4. `MainApp` 或真实业务主程序
|
||||||
|
5. `config/` 目录,可以先为空
|
||||||
|
|
||||||
|
### 6.1 标准目录结构和路径口径
|
||||||
|
|
||||||
|
更新系统不要求必须放在客户软件根目录。它可以放在 `SimCAE/` 根目录,也可以放在 `SimCAE/bin/` 目录。关键是让客户端配置里的 `install_root` 和服务端 Manifest 文件路径使用同一套口径。
|
||||||
|
|
||||||
|
先区分三个目录概念:
|
||||||
|
|
||||||
|
| 概念 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 运行目录 | Launcher、Updater、Bootstrap 所在目录,由客户端自动识别 |
|
||||||
|
| `install_root` | 相对运行目录解析的更新根目录,Updater 下载、校验、备份、回滚和 Bootstrap 替换文件都以它为范围 |
|
||||||
|
| Manifest `files[].path` | 服务端生成的安装相对路径,客户端会把它拼到 `install_root` 下面 |
|
||||||
|
|
||||||
|
目录结构一:更新系统放在软件根目录。
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/
|
SimCAE/
|
||||||
UpdateClientSDK/
|
Launcher.exe
|
||||||
sdk_manifest.json
|
Updater.exe
|
||||||
Docs/
|
Bootstrap.exe
|
||||||
00-先读我-客户端文档入口.txt
|
MainApp.exe
|
||||||
01-客户端接入打包部署指南.md
|
config/
|
||||||
02-编译环境和第三方依赖说明.md
|
app_config.json
|
||||||
bin/
|
App/
|
||||||
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:
|
```json
|
||||||
|
{
|
||||||
```powershell
|
"install_root": ".",
|
||||||
Expand-Archive D:\交付\UpdateClientSDK.zip -DestinationPath D:\SimCAE_SDK -Force
|
"main_executable": "MainApp.exe",
|
||||||
|
"launcher_executable": "Launcher.exe",
|
||||||
|
"updater_executable": "Updater.exe",
|
||||||
|
"bootstrap_executable": "Bootstrap.exe"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
再安装到业务软件的 `bin` 目录:
|
目录结构二:更新系统和启动入口放在 `bin/`。
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\SimCAE\bin
|
|
||||||
|
|
||||||
D:\SimCAE_SDK\scripts\install-sdk.ps1 `
|
|
||||||
-SdkRoot D:\SimCAE_SDK `
|
|
||||||
-ReleaseDir .
|
|
||||||
```
|
|
||||||
|
|
||||||
安装后目录应类似:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
D:\SimCAE\
|
SimCAE/
|
||||||
bin\
|
bin/
|
||||||
Launcher.exe
|
Launcher.exe
|
||||||
Updater.exe
|
Updater.exe
|
||||||
Bootstrap.exe
|
Bootstrap.exe
|
||||||
SimCAE.exe
|
MainApp.exe
|
||||||
config\
|
config/
|
||||||
app_config.json
|
app_config.json
|
||||||
manifest_public_key.pem
|
App/
|
||||||
Qt5Core.dll
|
|
||||||
...
|
...
|
||||||
Licenses\
|
|
||||||
installerResources\
|
|
||||||
```
|
```
|
||||||
|
|
||||||
如果你需要重新覆盖 `config/app_config.json`,执行安装脚本时加 `-OverwriteConfig`:
|
如果希望整个 `SimCAE/` 都属于更新范围,对应配置:
|
||||||
|
|
||||||
```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
|
```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": "..",
|
"install_root": "..",
|
||||||
"main_executable": "SimCAE.exe",
|
"main_executable": "MainApp.exe",
|
||||||
"launcher_executable": "Launcher.exe",
|
"launcher_executable": "Launcher.exe",
|
||||||
"updater_executable": "Updater.exe",
|
"updater_executable": "Updater.exe",
|
||||||
"bootstrap_executable": "Bootstrap.exe",
|
"bootstrap_executable": "Bootstrap.exe"
|
||||||
"health_check_timeout_ms": "15000",
|
|
||||||
"platform": "windows",
|
|
||||||
"arch": "x64"
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
对应的 `config/server_config.json` 示例:
|
这表示 Launcher 从 `bin/` 启动 `MainApp.exe`,Updater 和 Bootstrap 更新的是 `bin/` 的上一级,也就是整个 `SimCAE/`。
|
||||||
|
|
||||||
|
服务端发布包路径要和客户端 `install_root` 对应:
|
||||||
|
|
||||||
|
| 客户端配置 | 服务端 Manifest 路径口径 |
|
||||||
|
| --- | --- |
|
||||||
|
| 更新系统在根目录,`install_root` 是 `.` | `MainApp.exe`、`App/xxx.dll` 相对 `SimCAE/` |
|
||||||
|
| 更新系统在 `bin/`,`install_root` 是 `..` | `bin/MainApp.exe`、`App/xxx.dll` 相对 `SimCAE/` |
|
||||||
|
|
||||||
|
当前管理后台“发布包”上传接口会使用上传文件名作为 `artifactName`,后端按安全文件名校验。当前稳定支持的是发布一个完整安装包或压缩包文件,或者把文件放在 `install_root` 根层级;还不是“自动解析压缩包并生成 App/bin 多文件 Manifest”的完整安装器。后续如果要让在线 Updater 直接把多个文件铺到 `App/`、`bin/` 等子目录,需要在现有发布包页面和 Go 后端上继续增强安全相对路径或 Manifest 文件清单生成能力。
|
||||||
|
|
||||||
|
如果后续要支持“更新系统在 `bin/`,但只校验和更新 `App/`”这类更窄的安装根目录,需要在管理后台和 Go 后端增加对应配置项,让服务端生成 `../App` 这类定制 `install_root`。当前服务端自动生成配置时只使用标准的 `.` 或 `..`。
|
||||||
|
|
||||||
|
如果开启 `verify_installed_on_start`,客户端会扫描 `install_root` 下的 EXE 和 DLL。整包 Manifest 中 `required=true` 的核心文件必须存在且 SHA-256 匹配;`required=false` 的可选组件文件可以由 MaintenanceTool 管理,缺失时不会阻止启动。可选组件建议放在独立目录中,例如 `plugins/dap/`,不要和核心程序 DLL 混放。
|
||||||
|
|
||||||
|
上传完整客户软件 ZIP 时,服务端会根据发布包记录自动写入这些关键值:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"api_base_url": "http://192.168.229.128:8000"
|
"product_code": "stage2-dap",
|
||||||
|
"channel": "stable",
|
||||||
|
"current_version": "1.1.0",
|
||||||
|
"api_base_url": "http://192.168.1.158:18000",
|
||||||
|
"client_token": "<由服务器 .env 配置>",
|
||||||
|
"install_root": ". 或 ..",
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"abi": "msvc"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 五、你:业务主程序需要配合什么
|
`install_root` 由服务端根据 Launcher 所在位置自动判断:更新系统在软件根目录时写 `.`,在 `bin/` 目录时写 `..`。
|
||||||
|
|
||||||
当前安全模式下,业务主程序需要配合两件事:
|
## 7. 运行数据位置
|
||||||
|
|
||||||
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
|
Windows 运行数据目录:
|
||||||
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
|
|
||||||
|
|
||||||
接入位置:
|
`%LOCALAPPDATA%\SimCAE\HubUpdateClient\installations\<安装目录SHA256>\`
|
||||||
|
|
||||||
```text
|
Linux 运行数据目录:
|
||||||
main / WinMain 开头,创建主窗口之前
|
|
||||||
```
|
|
||||||
|
|
||||||
当前仓库里的 `update-client/MainApp/main.cpp` 是接入示例,已经实现:
|
`$XDG_DATA_HOME/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
|
||||||
|
|
||||||
- 启动票据校验。
|
未设置 `XDG_DATA_HOME` 时通常是:
|
||||||
- 本地 License/设备身份校验。
|
|
||||||
- 本地策略校验。
|
|
||||||
- Manifest 完整性校验。
|
|
||||||
- 健康标记写入。
|
|
||||||
|
|
||||||
真正接入业务软件时,把这些启动检查逻辑移植到业务主程序。用户入口应改成 `Launcher.exe`,不要让用户直接双击 `SimCAE.exe`。
|
`~/.local/share/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
|
||||||
|
|
||||||
重要:业务主程序校验 ticket 时,必须使用 SDK 当前运行配置里的动态值,不要直接从 `app_config.json` 读取 `device_id`。
|
Manifest 缓存保存在运行数据目录下的 `update/manifest_cache`。
|
||||||
|
|
||||||
原因是 `app_config.json` 是部署源文件,网页生成时 `device_id` 通常为空;真正的设备 ID 是 `Launcher.exe` 首次向服务端登记后写入当前用户注册表的。`Launcher.exe` 生成 ticket 时使用的是注册表里的真实 `device_id`。如果业务主程序从 `app_config.json` 读取空的 `device_id` 来校验,就会出现:
|
## 8. 常见问题
|
||||||
|
|
||||||
```text
|
1. 客户门户登录失败:检查客户门户账号是否已激活、客户是否生效、密码是否正确。
|
||||||
ticket signature, identity, time or nonce invalid
|
2. 检查更新没有结果:检查后台发布是否已发布、发布包是否可用、产品编码、渠道和平台参数是否一致。
|
||||||
```
|
3. 下载 401:检查发布包中的 `config/app_config.json` 是否由服务端生成,`client_token` 是否和服务器 `.env` 中的 `SIMCAE_CLIENT_TOKEN` 一致。
|
||||||
|
4. Manifest 校验失败:检查服务端 Manifest 是否被篡改、发布包 SHA-256 是否和实际文件一致。
|
||||||
或者细化后的:
|
5. 强制签名失败:确认 `manifest_public_key.pem` 与服务端私钥匹配;如果服务端暂未启用签名,测试环境可先把 `require_manifest_signature` 设为 `false`。
|
||||||
|
6. 启动主程序失败:检查 `main_executable` 和 `install_root` 是否指向真实文件。
|
||||||
```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`。
|
|
||||||
|
|||||||
+44
-144
@@ -1,175 +1,75 @@
|
|||||||
# 客户端编译环境和第三方依赖说明
|
# SimCAE Hub 客户端编译环境和第三方依赖说明
|
||||||
|
|
||||||
`thirdparty/` 是本机依赖目录,已经被 `.gitignore` 忽略,不会提交到 Git。
|
本文说明 `update-client` 的 Qt/C++ 客户端编译环境。客户端保留 Launcher / Updater / Bootstrap 机制,依赖 Qt、CMake 和 OpenSSL。
|
||||||
|
|
||||||
当前客户端构建依赖:
|
## 1. 通用要求
|
||||||
|
|
||||||
1. Qt 5.15.2 或兼容的 Qt 5 版本
|
| 依赖 | 要求 |
|
||||||
2. OpenSSL
|
| --- | --- |
|
||||||
|
| CMake | 建议 3.20 或更高版本 |
|
||||||
|
| C++ | C++17 |
|
||||||
|
| Qt | Qt 5,至少需要 Core、Network、Gui、Widgets |
|
||||||
|
| OpenSSL | 用于 Manifest RSA-SHA256 验签 |
|
||||||
|
| 编译器 | Windows 推荐 Visual Studio 2022 x64,Linux 推荐 gcc/g++ |
|
||||||
|
|
||||||
Windows 下推荐使用 Qt 5.15.2 msvc2019_64 和 OpenSSL-Win64;Linux 下使用系统安装的 Qt/OpenSSL 开发包。
|
项目已提供 CMake Preset:
|
||||||
|
|
||||||
先看结论:
|
| Preset | 平台 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `x64-debug` | Windows | Debug 编译 |
|
||||||
|
| `x64-release` | Windows | Release 编译 |
|
||||||
|
| `linux-x64-debug` | Linux | Debug 编译 |
|
||||||
|
| `linux-x64-release` | Linux | Release 编译 |
|
||||||
|
|
||||||
- Windows:配置 Qt 环境变量,把 OpenSSL 复制到 `thirdparty/OpenSSL-Win64`。
|
## 2. Windows 环境
|
||||||
- Linux:用 apt 安装 Qt/OpenSSL 开发包。
|
|
||||||
- `thirdparty/` 只放本机依赖,不提交 Git。
|
|
||||||
|
|
||||||
## 1. Qt 配置
|
建议安装:
|
||||||
|
|
||||||
### Windows
|
1. Visual Studio 2022,勾选 Desktop development with C++。
|
||||||
|
2. Qt 5 x64,版本可以与当前团队环境保持一致。
|
||||||
|
3. CMake。
|
||||||
|
4. OpenSSL x64。
|
||||||
|
|
||||||
Qt 路径由本机环境变量提供。你需要在 Windows 环境变量里配置 Qt 路径,让 CMake 的 `find_package(Qt5 ...)` 能找到 Qt。
|
如果 Qt 没有加入环境变量,可以在编译前指定 `CMAKE_PREFIX_PATH` 或 `Qt5_DIR`。示例:
|
||||||
|
|
||||||
推荐配置用户环境变量 `CMAKE_PREFIX_PATH`:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
|
$env:CMAKE_PREFIX_PATH = "C:\Qt\5.15.2\msvc2019_64"
|
||||||
```
|
```
|
||||||
|
|
||||||
设置完成后,重新打开 PowerShell 或 Visual Studio。
|
如果 Qt 安装在别的位置,只改这一行。
|
||||||
|
|
||||||
如果只想对当前 PowerShell 窗口临时生效:
|
OpenSSL 可以放在 `update-client/thirdparty/OpenSSL-Win64`,也可以在配置时通过 `SIMCAE_OPENSSL_ROOT` 指向自定义目录。
|
||||||
|
|
||||||
```powershell
|
## 3. Linux 环境
|
||||||
$env:CMAKE_PREFIX_PATH="C:\Qt\5.15.2\msvc2019_64"
|
|
||||||
```
|
|
||||||
|
|
||||||
也可以配置更精确的 `Qt5_DIR`:
|
Ubuntu 示例:
|
||||||
|
|
||||||
```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
|
```bash
|
||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev-tools libssl-dev
|
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev qttools5-dev-tools libssl-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
如果 Qt 安装在自定义目录,可以临时设置:
|
Linux 下通常直接使用系统 OpenSSL;如需指定自定义 OpenSSL,可用 CMake 变量配置。
|
||||||
|
|
||||||
```bash
|
## 4. 编译输出
|
||||||
export CMAKE_PREFIX_PATH=/path/to/Qt/5.x/gcc_64
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. OpenSSL 配置
|
Windows Release 可执行文件输出目录:
|
||||||
|
|
||||||
### Windows
|
`update-client/out/bin/Release`
|
||||||
|
|
||||||
OpenSSL 默认放在:
|
Windows CMake 构建目录:
|
||||||
|
|
||||||
```text
|
`update-client/out/build/x64-release`
|
||||||
thirdparty/OpenSSL-Win64
|
|
||||||
```
|
|
||||||
|
|
||||||
推荐目录结构:
|
Linux Release 可执行文件输出目录以当前 CMake Preset 和构建脚本为准,SDK 打包时通过 `--source-dir` 指定。
|
||||||
|
|
||||||
```text
|
实际打包 SDK 前,应确认 Release 输出目录中至少包含:
|
||||||
thirdparty/
|
|
||||||
OpenSSL-Win64/
|
|
||||||
include/
|
|
||||||
openssl/
|
|
||||||
lib/
|
|
||||||
VC/
|
|
||||||
x64/
|
|
||||||
MD/
|
|
||||||
MDd/
|
|
||||||
```
|
|
||||||
|
|
||||||
复制命令示例:
|
1. `Launcher`
|
||||||
|
2. `Updater`
|
||||||
|
3. `Bootstrap`
|
||||||
|
4. 可选的示例 `MainApp`
|
||||||
|
|
||||||
```powershell
|
最终客户软件包里的 `config/app_config.json` 和 `config/manifest_public_key.pem` 由服务端在发布包上传时生成;`server_config.json` 会编译进 EXE 作为兜底地址,不需要进入 SDK 包。
|
||||||
cd C:\Users\admin\Desktop\update-client
|
|
||||||
mkdir thirdparty
|
|
||||||
Copy-Item "C:\Program Files\OpenSSL-Win64" ".\thirdparty\OpenSSL-Win64" -Recurse
|
|
||||||
```
|
|
||||||
|
|
||||||
如果 OpenSSL 不放在 `thirdparty/`,可以在配置 CMake 时手动指定:
|
SDK 打包见 `../打包成SDK.md`,客户端运行链路和接入限制见 `01-客户端接入打包部署指南.md`。
|
||||||
|
|
||||||
```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。
|
|
||||||
|
|||||||
+202
-158
@@ -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();
|
||||||
|
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())
|
||||||
{
|
{
|
||||||
qDebug() << "Config incomplete, abort update check";
|
m_error = QCoreApplication::translate(
|
||||||
m_needUpdate = false;
|
"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;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.updateOnlineVerified(onlinePolicy.policySeq());
|
|
||||||
if (!state.saveState())
|
|
||||||
{
|
|
||||||
qDebug() << "Cannot persist online policy state";
|
|
||||||
m_networkOk = false;
|
|
||||||
m_needUpdate = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
m_needUpdate = resp["need_update"].toBool();
|
|
||||||
m_latestVer = resp["latest_version"].toString();
|
|
||||||
qDebug() << "Need update:" << m_needUpdate;
|
|
||||||
qDebug() << "Latest version:" << m_latestVer;
|
|
||||||
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
m_needUpdate = false;
|
m_needUpdate = false;
|
||||||
qDebug() << "Check update api failed";
|
m_error = serverMessage(resp);
|
||||||
|
qDebug() << "Check update api failed:" << m_error;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QJsonArray releases = resp.value(QStringLiteral("data")).toArray();
|
||||||
|
QJsonObject selected;
|
||||||
|
for (const QJsonValue& value : releases) {
|
||||||
|
const QJsonObject release = value.toObject();
|
||||||
|
const bool hasPackages = !release.value(QStringLiteral("packages")).toArray().isEmpty();
|
||||||
|
const bool hasManifest = release.value(QStringLiteral("manifest")).isObject()
|
||||||
|
|| !release.value(QStringLiteral("manifestUri")).toString().isEmpty();
|
||||||
|
if (hasPackages && hasManifest) {
|
||||||
|
selected = release;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty()) {
|
||||||
|
m_needUpdate = false;
|
||||||
|
if (!releases.isEmpty())
|
||||||
|
m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString();
|
||||||
|
qDebug() << "No entitled downloadable update for current client.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_checkResp = selected;
|
||||||
|
m_checkResp.insert(QStringLiteral("need_update"), true);
|
||||||
|
m_checkResp.insert(QStringLiteral("latest_version"), selected.value(QStringLiteral("version")).toString());
|
||||||
|
m_checkResp.insert(QStringLiteral("release_id"), selected.value(QStringLiteral("id")).toString());
|
||||||
|
m_needUpdate = true;
|
||||||
|
m_latestVer = selected.value(QStringLiteral("version")).toString();
|
||||||
|
qDebug() << "Need update:" << m_needUpdate;
|
||||||
|
qDebug() << "Latest version:" << m_latestVer;
|
||||||
|
qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
|
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+100
-290
@@ -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")
|
|
||||||
|| text.contains("authorization")
|
|
||||||
|| text.contains("expired")
|
|
||||||
|| text.contains("device limit")
|
|
||||||
|| text.contains("bound to another license");
|
|
||||||
};
|
|
||||||
const auto clearDeviceCredential = [&]() {
|
|
||||||
ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath());
|
|
||||||
config.setValue("Update", "device_id", QString());
|
|
||||||
};
|
|
||||||
QString licenseKey = config.getValue("License", "license_key").trimmed();
|
|
||||||
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
|
|
||||||
QString promptReason = reason;
|
|
||||||
while (true) {
|
|
||||||
progress.close();
|
|
||||||
bool accepted = false;
|
|
||||||
const QString appName = config.getValue("App", "app_name").trimmed();
|
|
||||||
const QString prompt = promptReason.trimmed().isEmpty()
|
|
||||||
? QCoreApplication::translate("Launcher", "Please enter the License for %1:")
|
|
||||||
.arg(appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName)
|
|
||||||
: QCoreApplication::translate("Launcher", "%1\n\nPlease enter a new License for %2:")
|
|
||||||
.arg(promptReason, appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName);
|
|
||||||
licenseKey = QInputDialog::getText(
|
|
||||||
nullptr,
|
|
||||||
QCoreApplication::translate("Launcher", "Enter License"),
|
|
||||||
prompt,
|
|
||||||
QLineEdit::Normal,
|
|
||||||
QString(),
|
|
||||||
&accepted
|
|
||||||
).trimmed();
|
|
||||||
if (!accepted) {
|
|
||||||
QMessageBox::information(nullptr,
|
|
||||||
QCoreApplication::translate("Launcher", "License Required"),
|
|
||||||
QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch."));
|
|
||||||
return QString();
|
|
||||||
}
|
|
||||||
if (licenseKey.isEmpty()) {
|
|
||||||
QMessageBox::warning(nullptr,
|
|
||||||
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
|
|
||||||
QCoreApplication::translate("Launcher", "Please paste the License created in the admin page."));
|
|
||||||
promptReason.clear();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!config.setValue("License", "license_key", licenseKey)) {
|
|
||||||
QMessageBox::critical(nullptr,
|
|
||||||
QCoreApplication::translate("Launcher", "Failed to Save License"),
|
|
||||||
QCoreApplication::translate("Launcher", "Cannot write the configuration file:\n%1\n%2").arg(config.configPath(), config.lastError()));
|
|
||||||
return QString();
|
|
||||||
}
|
|
||||||
progress.show();
|
|
||||||
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License..."));
|
|
||||||
QApplication::processEvents();
|
|
||||||
return licenseKey;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。
|
|
||||||
// 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。
|
|
||||||
if (licenseKey.isEmpty()) {
|
|
||||||
licenseKey = promptAndSaveLicense(QString());
|
|
||||||
if (licenseKey.isEmpty()) return 0;
|
|
||||||
}
|
|
||||||
DeviceIdentityHelper identity(appDir);
|
|
||||||
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
|
|
||||||
config.getValue("Server", "client_token"),
|
|
||||||
config.getValue("App", "app_id"),
|
|
||||||
config.getValue("App", "channel"),
|
|
||||||
licenseKey)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const QString error = identity.errorString();
|
|
||||||
if (!isLicenseError(error)) {
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr,
|
|
||||||
QCoreApplication::translate("Launcher", "Device Authentication Failed"),
|
|
||||||
error);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
clearDeviceCredential();
|
|
||||||
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error));
|
|
||||||
if (licenseKey.isEmpty()) return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates..."));
|
||||||
|
QApplication::processEvents();
|
||||||
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();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr,
|
QMessageBox::warning(nullptr,
|
||||||
QCoreApplication::translate("Launcher", "Cannot Start"),
|
QCoreApplication::translate("Launcher", "Update Server Unavailable"),
|
||||||
QCoreApplication::translate("Launcher", "The local version policy has expired. Please connect to the network or contact the administrator."));
|
QCoreApplication::translate("Launcher", "Cannot connect to the update server. The installed application will be started without downloading an update."));
|
||||||
return -1;
|
progress.show();
|
||||||
}
|
|
||||||
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();
|
|
||||||
QMessageBox::warning(nullptr,
|
|
||||||
QCoreApplication::translate("Launcher", "Git Tag List Failed"),
|
|
||||||
QCoreApplication::translate("Launcher", "Cannot generate tags.txt, but startup will continue:\n%1").arg(logic.errorString()));
|
|
||||||
progress.show();
|
|
||||||
QApplication::processEvents();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+55
-75
@@ -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")))
|
IntegrityHelper integrity(installRoot);
|
||||||
{
|
if (!integrity.verifyInstalledVersion(
|
||||||
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
|
productCode(),
|
||||||
return -1;
|
config.getValue(QStringLiteral("App"), QStringLiteral("channel")),
|
||||||
}
|
config.getValue(QStringLiteral("App"), QStringLiteral("current_version"))))
|
||||||
PolicyHelper policy(appDir);
|
{
|
||||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
QMessageBox::critical(nullptr, "Integrity Check Failed",
|
||||||
{
|
QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1")
|
||||||
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
|
.arg(integrity.errorString()));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (policy.isExpired())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalStateHelper state(appDir);
|
|
||||||
if (!state.loadState())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isPolicySeqRolledBack(policy.policySeq()))
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isSystemTimeRewound())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
IntegrityHelper integrity(installRoot);
|
|
||||||
if (!integrity.verifyInstalledVersion(
|
|
||||||
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
|
||||||
config.getValue("App", "current_version")))
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Integrity Check Failed",
|
|
||||||
QString("Startup blocked because the installed files failed local signed Manifest verification.\n"
|
|
||||||
"This is not a download check; it means the current installation directory does not match the signed Manifest cache for this version.\n\n"
|
|
||||||
"Details:\n%1")
|
|
||||||
.arg(integrity.errorString()));
|
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
+214
-155
@@ -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,13 +292,30 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
|||||||
"No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
|
"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)
|
||||||
{
|
{
|
||||||
qDebug() << "Manifest signature empty";
|
if (requireSignature) {
|
||||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
qDebug() << "Manifest signature empty";
|
||||||
"The manifest does not contain a signature. Stage: manifest signature verification.");
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
return false;
|
"The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled.";
|
||||||
|
m_error.clear();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString path = publicKeyPath;
|
QString path = publicKeyPath;
|
||||||
@@ -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;
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
body["app_id"] = appId;
|
"The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads.");
|
||||||
body["channel"] = channel;
|
} else {
|
||||||
body["version"] = targetVer;
|
qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest.";
|
||||||
body["version_id"] = versionId;
|
}
|
||||||
|
emit fetchUrlFinished();
|
||||||
QJsonArray emptyFiles;
|
|
||||||
body["files"] = emptyFiles;
|
|
||||||
|
|
||||||
m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp)
|
|
||||||
{
|
|
||||||
qDebug() << "Download URL API returned code:" << code;
|
|
||||||
m_fileItems.clear();
|
|
||||||
|
|
||||||
if (code == 200)
|
|
||||||
{
|
|
||||||
QJsonArray fileArr = resp["files"].toArray();
|
|
||||||
for (auto item : fileArr)
|
|
||||||
{
|
|
||||||
QJsonObject obj = item.toObject();
|
|
||||||
FileDownloadItem fi;
|
|
||||||
fi.path = obj["path"].toString();
|
|
||||||
fi.url = obj["url"].toString();
|
|
||||||
fi.sha256 = obj["sha256"].toString();
|
|
||||||
fi.size = obj["size"].toVariant().toLongLong();
|
|
||||||
m_fileItems.append(fi);
|
|
||||||
qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
qDebug() << "Failed to get download URL";
|
|
||||||
const QString detail = serverDetailMessage(resp);
|
|
||||||
m_error = QCoreApplication::translate("UpdaterLogic",
|
|
||||||
"Cannot get secure download URLs. Stage: request download URLs. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
|
|
||||||
.arg(QString::number(code), appId, channel, targetVer, QString::number(versionId),
|
|
||||||
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
|
|
||||||
}
|
|
||||||
emit fetchUrlFinished();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"api_base_url": "http://192.168.1.158:8000"
|
|
||||||
}
|
|
||||||
@@ -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.
+339
-635
File diff suppressed because it is too large
Load Diff
+17
-43
@@ -1,59 +1,33 @@
|
|||||||
客户端脚本说明
|
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
|
../打包成SDK.md
|
||||||
.\scripts\package-sdk.ps1 `
|
|
||||||
-SourceDir .\out\bin\Release `
|
|
||||||
-OutputDir .\dist\UpdateClientSDK `
|
|
||||||
-ZipFile .\dist\UpdateClientSDK.zip `
|
|
||||||
-SdkVersion 0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
生成 SDK 后,把 Launcher、Updater、Bootstrap 和必要运行库放进业务软件
|
||||||
.\scripts\package-client.ps1 `
|
根目录或 bin 目录,再把完整软件目录压缩上传到 SimCAE Hub 管理后台的
|
||||||
-SourceDir .\out\bin\Release `
|
发布包页面。服务端会生成 config/app_config.json,并在启用 Manifest 签名
|
||||||
-ConfigFile .\config\app_config.json `
|
时生成 config/manifest_public_key.pem。
|
||||||
-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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-21
@@ -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
|
||||||
)
|
)
|
||||||
@@ -15,17 +14,13 @@ 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/SimCAEHubUpdateClientSDK"
|
||||||
}
|
}
|
||||||
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
|
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
|
||||||
$ZipFile = Join-Path $RepoRoot "dist/UpdateClientSDK.zip"
|
$ZipFile = Join-Path $RepoRoot "dist/SimCAEHubUpdateClientSDK.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 +30,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')
|
||||||
@@ -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
|
||||||
@@ -144,6 +135,8 @@ 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
|
||||||
|
contains_qt_runtime = [bool]$IncludeQtRuntime
|
||||||
|
contains_final_config = $false
|
||||||
docs_entry = "Docs/01-客户端接入打包部署指南.md"
|
docs_entry = "Docs/01-客户端接入打包部署指南.md"
|
||||||
word_guide_included = [bool]$wordGuideSource
|
word_guide_included = [bool]$wordGuideSource
|
||||||
integration_sources = $commonSourceFiles
|
integration_sources = $commonSourceFiles
|
||||||
|
|||||||
+33
-28
@@ -5,11 +5,11 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
|
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
|
||||||
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux"
|
OUTPUT_DIR="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-linux"
|
||||||
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz"
|
ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-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'
|
||||||
@@ -17,11 +17,11 @@ Usage: package-sdk.sh [options]
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
|
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
|
||||||
--output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux
|
--output-dir DIR SDK directory to generate. Default: ./dist/SimCAEHubUpdateClientSDK-linux
|
||||||
--archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz
|
--archive FILE SDK tar.gz path. Default: ./dist/SimCAEHubUpdateClientSDK-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
|
||||||
@@ -76,6 +60,21 @@ 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" "$OUTPUT_DIR/Docs"
|
||||||
|
|
||||||
|
is_qt_runtime_item() {
|
||||||
|
local base="$1"
|
||||||
|
case "$base" in
|
||||||
|
bearer|iconengines|imageformats|platforms|styles|translations)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
libQt5*.so*|libEGL.so*|libGLESv2.so*|libqxcb.so*|libxcb*.so*|libstdc++.so*|libgcc_s.so*|libssl.so*|libcrypto.so*|opengl32sw.dll|d3dcompiler_47.dll)
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
shopt -s dotglob nullglob
|
shopt -s dotglob nullglob
|
||||||
for item in "$SOURCE_DIR"/*; do
|
for item in "$SOURCE_DIR"/*; do
|
||||||
base="$(basename "$item")"
|
base="$(basename "$item")"
|
||||||
@@ -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
|
||||||
@@ -134,13 +133,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),
|
||||||
|
"contains_qt_runtime": $([[ "$INCLUDE_QT_RUNTIME" -eq 1 ]] && echo true || echo false),
|
||||||
|
"contains_final_config": false,
|
||||||
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
|
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
|
||||||
"word_guide_included": $WORD_GUIDE_INCLUDED,
|
"word_guide_included": $WORD_GUIDE_INCLUDED,
|
||||||
"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
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Hub 更新客户端打包成 SDK
|
||||||
|
|
||||||
|
本文只说明如何从 `SIMCAE/update-client` 生成给 SIMCAE 开发者使用的 SDK 包。SIMCAE 如何拿这个 SDK 打客户安装器和交付包,见项目根目录的 [客户端部署.md](../../客户端部署.md)。
|
||||||
|
|
||||||
|
## 一、SDK 包含什么
|
||||||
|
|
||||||
|
SDK 用来把 Hub 更新客户端接入 SIMCAE 安装包。
|
||||||
|
|
||||||
|
| 内容 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `Launcher.exe` | 客户日常启动入口,检查整包更新并启动主程序 |
|
||||||
|
| `Updater.exe` | 拉取 Manifest、下载发布包、校验 SHA-256、准备安装 |
|
||||||
|
| `Bootstrap.exe` | 替换运行中文件时接管安装 |
|
||||||
|
| Qt 运行库 | 可选,给没有单独 Qt 运行环境的接入方使用 |
|
||||||
|
| 文档 | 说明客户端目录结构、配置字段和接入方式 |
|
||||||
|
|
||||||
|
SDK 不包含最终客户配置文件,例如 `app_config.json`、`server_config.json`、`server_config.qrc`、`manifest_public_key.pem`。这些文件由服务端在上传客户软件包或 Qt IFW 交付包时生成或注入。
|
||||||
|
|
||||||
|
## 二、编译 Release
|
||||||
|
|
||||||
|
先进入 SIMCAE 仓库下的 `update-client` 目录。如果当前已经在 SIMCAE 仓库根目录:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd .\update-client
|
||||||
|
```
|
||||||
|
|
||||||
|
然后执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cmake --preset x64-release
|
||||||
|
cmake --build --preset x64-release
|
||||||
|
```
|
||||||
|
|
||||||
|
编译完成后,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\SimCAEHubUpdateClientSDK `
|
||||||
|
-ZipFile .\dist\SimCAEHubUpdateClientSDK.zip `
|
||||||
|
-SdkVersion 0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
| 输出 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dist\SimCAEHubUpdateClientSDK` | SDK 展开目录 |
|
||||||
|
| `dist\SimCAEHubUpdateClientSDK.zip` | 可交给 SIMCAE 开发者的 SDK 压缩包 |
|
||||||
|
|
||||||
|
## 四、打包带 Qt 运行库的 SDK
|
||||||
|
|
||||||
|
适用于接入方不想单独准备 Qt DLL,或者希望拿到后能直接放进安装包。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\package-sdk.ps1 `
|
||||||
|
-SourceDir .\out\bin\Release `
|
||||||
|
-OutputDir .\dist\SimCAEHubUpdateClientSDK-with-qt `
|
||||||
|
-ZipFile .\dist\SimCAEHubUpdateClientSDK-with-qt.zip `
|
||||||
|
-SdkVersion 0.1.0 `
|
||||||
|
-IncludeQtRuntime
|
||||||
|
```
|
||||||
|
|
||||||
|
输出:
|
||||||
|
|
||||||
|
| 输出 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dist\SimCAEHubUpdateClientSDK-with-qt` | 带 Qt 运行库的 SDK 展开目录 |
|
||||||
|
| `dist\SimCAEHubUpdateClientSDK-with-qt.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
|
||||||
|
|
||||||
|
## 五、打包后检查
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Launcher.exe
|
||||||
|
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Updater.exe
|
||||||
|
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Bootstrap.exe
|
||||||
|
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
预期都返回 `True`。
|
||||||
|
|
||||||
|
## 六、不要提交的内容
|
||||||
|
|
||||||
|
`SIMCAE/update-client/.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、客户配置和运行状态提交进仓库。
|
||||||
Reference in New Issue
Block a user