feat(hardening): 内嵌产品配置 + 系统级状态 + 全量 i18n + 依赖治理 + 资料清理
update-client-hardening 四工作流合入(构建/静态检查已过): - 配置:不可变产品策略经 UpdateClientResources.cmake 归一化校验后 内嵌 qrc(URL/相对路径/主程序必须落 install_root/版本/UUID 格式, CMake override 写入前复验);可变机器状态迁系统级原生存储; 运行目录不再落可编辑 app_config.json。 - i18n:生产源全英文(no-Han 检查 41/41),中文进 translations/ zh_CN 目录;翻译加载收敛 Common/TranslationHelper 唯一入口; Bootstrap 补资源文件。 - 依赖:Qt/OpenSSL/架构/Perl 机器路径改 imported targets + UpdateClientDependencies.cmake,3rdparty 由父工程契约供给。 - 清理:旧 README/SDK README/3 个重复 PowerShell 打包脚本/PDF 提取 文本删除,canonical README + 5 份结构化英文文档替代;Launcher requireAdministrator manifest;统一 MSVC /utf-8 删运行时编码设置。
This commit is contained in:
+14
-19
@@ -1,7 +1,4 @@
|
||||
project(Common LANGUAGES C CXX)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
|
||||
|
||||
set(SRC
|
||||
set(_common_sources
|
||||
HttpHelper.h
|
||||
HttpHelper.cpp
|
||||
FileHelper.h
|
||||
@@ -18,21 +15,19 @@ set(SRC
|
||||
IntegrityHelper.cpp
|
||||
DeviceIdentityHelper.h
|
||||
DeviceIdentityHelper.cpp
|
||||
)
|
||||
add_library(Common STATIC ${SRC})
|
||||
|
||||
# Common编译自身需要OpenSSL头文件
|
||||
target_include_directories(Common
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${OPENSSL_INC}
|
||||
TranslationHelper.h
|
||||
TranslationHelper.cpp
|
||||
)
|
||||
|
||||
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
|
||||
target_link_directories(Common INTERFACE
|
||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
|
||||
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
|
||||
)
|
||||
add_library(Common STATIC ${_common_sources})
|
||||
target_compile_features(Common PUBLIC cxx_std_17)
|
||||
target_compile_definitions(Common PUBLIC HAVE_OPENSSL=1)
|
||||
target_include_directories(Common PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(Common
|
||||
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
|
||||
INTERFACE libssl.lib libcrypto.lib
|
||||
)
|
||||
PUBLIC
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Widgets
|
||||
OpenSSL::SSL
|
||||
OpenSSL::Crypto
|
||||
)
|
||||
|
||||
+565
-105
@@ -1,13 +1,162 @@
|
||||
#include "ConfigHelper.h"
|
||||
#include <QApplication>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QJsonParseError>
|
||||
#include <QRegularExpression>
|
||||
#include <QSettings>
|
||||
#include <QDebug>
|
||||
#include <QUrl>
|
||||
#include <QUuid>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr auto kProductConfigResource = ":/simcae/update-client/product-config.json";
|
||||
constexpr auto kSettingsOrganization = "SimCAE";
|
||||
constexpr auto kSettingsApplication = "UpdateClient";
|
||||
constexpr auto kMigrationMarker = "meta/legacy_state_migration_version";
|
||||
constexpr int kMigrationVersion = 1;
|
||||
|
||||
const QStringList& mutableKeys()
|
||||
{
|
||||
static const QStringList keys{
|
||||
QStringLiteral("current_version"),
|
||||
QStringLiteral("license_key"),
|
||||
QStringLiteral("device_id"),
|
||||
QStringLiteral("installation_id")
|
||||
};
|
||||
return keys;
|
||||
}
|
||||
|
||||
const QStringList& legacyMigratableKeys()
|
||||
{
|
||||
static const QStringList keys{
|
||||
QStringLiteral("license_key"),
|
||||
QStringLiteral("device_id"),
|
||||
QStringLiteral("installation_id")
|
||||
};
|
||||
return keys;
|
||||
}
|
||||
|
||||
QString settingsStatusText(QSettings::Status status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case QSettings::NoError:
|
||||
return QStringLiteral("no error");
|
||||
case QSettings::AccessError:
|
||||
return QStringLiteral("access denied");
|
||||
case QSettings::FormatError:
|
||||
return QStringLiteral("invalid settings format");
|
||||
}
|
||||
return QStringLiteral("unknown settings error");
|
||||
}
|
||||
|
||||
bool isSafeIdentifier(const QString& value)
|
||||
{
|
||||
static const QRegularExpression pattern(QStringLiteral("^[A-Za-z0-9._-]+$"));
|
||||
return pattern.match(value).hasMatch();
|
||||
}
|
||||
|
||||
bool isStrictRelativePath(const QString& value)
|
||||
{
|
||||
const QString relative = QDir::fromNativeSeparators(value.trimmed());
|
||||
if (relative.isEmpty()
|
||||
|| QDir::isAbsolutePath(relative)
|
||||
|| relative.contains(QLatin1Char(':'))
|
||||
|| QDir::cleanPath(relative) != relative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList segments = relative.split(QLatin1Char('/'));
|
||||
for (const QString& segment : segments)
|
||||
{
|
||||
if (segment.isEmpty() || segment == QStringLiteral(".") || segment == QStringLiteral(".."))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isPathWithinRoot(const QString& rootPath, const QString& candidatePath)
|
||||
{
|
||||
const QString relative = QDir::fromNativeSeparators(
|
||||
QDir(rootPath).relativeFilePath(candidatePath));
|
||||
return !QDir::isAbsolutePath(relative)
|
||||
&& relative != QStringLiteral("..")
|
||||
&& !relative.startsWith(QStringLiteral("../"));
|
||||
}
|
||||
|
||||
bool isControlledMainExecutablePath(const QString& value, const QString& installRoot)
|
||||
{
|
||||
const QString relative = QDir::fromNativeSeparators(value.trimmed());
|
||||
if (relative.isEmpty()
|
||||
|| QDir::isAbsolutePath(relative)
|
||||
|| relative.contains(QLatin1Char(':'))
|
||||
|| QDir::cleanPath(relative) != relative
|
||||
|| relative == QStringLiteral(".")
|
||||
|| relative == QStringLiteral(".."))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString runtimeRoot = QDir::cleanPath(QCoreApplication::applicationDirPath());
|
||||
const QString approvedRoot = QDir::cleanPath(QDir(runtimeRoot).filePath(installRoot));
|
||||
const QString executablePath = QDir::cleanPath(QDir(runtimeRoot).filePath(relative));
|
||||
return isPathWithinRoot(approvedRoot, executablePath);
|
||||
}
|
||||
|
||||
bool isPositiveInteger(const QJsonValue& value)
|
||||
{
|
||||
if (!value.isString() && !value.isDouble())
|
||||
return false;
|
||||
|
||||
bool ok = false;
|
||||
const int number = value.toVariant().toString().toInt(&ok);
|
||||
return ok && number > 0;
|
||||
}
|
||||
|
||||
bool hasControlCharacter(const QString& value)
|
||||
{
|
||||
for (const QChar character : value)
|
||||
{
|
||||
if (character.category() == QChar::Other_Control)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool normalizeMutableStateValue(const QString& key, const QString& rawValue, QString* normalizedValue)
|
||||
{
|
||||
const QString value = rawValue.trimmed();
|
||||
bool valid = false;
|
||||
|
||||
if (key == QStringLiteral("current_version"))
|
||||
{
|
||||
static const QRegularExpression versionPattern(
|
||||
QStringLiteral("^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"));
|
||||
valid = versionPattern.match(value).hasMatch();
|
||||
}
|
||||
else if (key == QStringLiteral("installation_id"))
|
||||
{
|
||||
valid = value.isEmpty() || !QUuid(value).isNull();
|
||||
}
|
||||
else if (key == QStringLiteral("device_id"))
|
||||
{
|
||||
valid = value.size() <= 256 && !hasControlCharacter(value);
|
||||
}
|
||||
else if (key == QStringLiteral("license_key"))
|
||||
{
|
||||
valid = value.size() <= 4096 && !hasControlCharacter(value);
|
||||
}
|
||||
|
||||
if (valid && normalizedValue)
|
||||
*normalizedValue = value;
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
|
||||
ConfigHelper& ConfigHelper::instance()
|
||||
{
|
||||
@@ -16,43 +165,73 @@ ConfigHelper& ConfigHelper::instance()
|
||||
}
|
||||
|
||||
ConfigHelper::ConfigHelper()
|
||||
: m_productConfigPath(QString::fromLatin1(kProductConfigResource))
|
||||
{
|
||||
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
|
||||
migrateLegacyIniIfNeeded();
|
||||
qDebug() << "Loading app config path:" << m_configPath;
|
||||
qDebug() << "File exists?" << QFile::exists(m_configPath);
|
||||
if (!loadProductConfig())
|
||||
{
|
||||
qCritical().noquote() << m_error;
|
||||
return;
|
||||
}
|
||||
|
||||
m_statePrefix = QStringLiteral("products/%1/%2/")
|
||||
.arg(m_productConfig.value(QStringLiteral("app_id")).toString(),
|
||||
m_productConfig.value(QStringLiteral("channel")).toString());
|
||||
|
||||
if (!migrateLegacyStateIfNeeded())
|
||||
qWarning().noquote() << m_error;
|
||||
if (!initializeStateDefaults())
|
||||
qWarning().noquote() << m_error;
|
||||
}
|
||||
|
||||
QString ConfigHelper::configPath() const
|
||||
{
|
||||
return m_configPath;
|
||||
return stateLocation();
|
||||
}
|
||||
|
||||
QString ConfigHelper::productConfigPath() const
|
||||
{
|
||||
return m_productConfigPath;
|
||||
}
|
||||
|
||||
QString ConfigHelper::stateLocation() const
|
||||
{
|
||||
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
|
||||
QString::fromLatin1(kSettingsOrganization),
|
||||
QString::fromLatin1(kSettingsApplication));
|
||||
settings.setFallbacksEnabled(false);
|
||||
const QString nativeLocation = settings.fileName();
|
||||
if (!nativeLocation.isEmpty())
|
||||
return nativeLocation;
|
||||
return QStringLiteral("Native system settings: %1/%2")
|
||||
.arg(QString::fromLatin1(kSettingsOrganization),
|
||||
QString::fromLatin1(kSettingsApplication));
|
||||
}
|
||||
|
||||
QString ConfigHelper::installRoot() const
|
||||
{
|
||||
QString relativeRoot = getValue("Runtime", "install_root").trimmed();
|
||||
QString relativeRoot = getValue(QStringLiteral("Runtime"), QStringLiteral("install_root")).trimmed();
|
||||
if (relativeRoot.isEmpty())
|
||||
relativeRoot = ".";
|
||||
relativeRoot = QStringLiteral(".");
|
||||
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
|
||||
}
|
||||
|
||||
QString ConfigHelper::runtimeRoot() const
|
||||
{
|
||||
return QDir::cleanPath(QApplication::applicationDirPath());
|
||||
return QDir::cleanPath(QCoreApplication::applicationDirPath());
|
||||
}
|
||||
|
||||
QString ConfigHelper::updateRoot() const
|
||||
{
|
||||
return QDir(runtimeRoot()).filePath("update");
|
||||
return QDir(runtimeRoot()).filePath(QStringLiteral("update"));
|
||||
}
|
||||
|
||||
QString ConfigHelper::runtimeRelativePath() const
|
||||
{
|
||||
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
|
||||
relative = QDir::cleanPath(relative);
|
||||
if (relative == ".")
|
||||
if (relative == QStringLiteral("."))
|
||||
return QString();
|
||||
while (relative.startsWith("./"))
|
||||
while (relative.startsWith(QStringLiteral("./")))
|
||||
relative = relative.mid(2);
|
||||
return relative.trimmed();
|
||||
}
|
||||
@@ -65,16 +244,9 @@ QString ConfigHelper::lastError() const
|
||||
QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
||||
{
|
||||
Q_UNUSED(section);
|
||||
QFile file(m_configPath);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return QString();
|
||||
|
||||
QJsonParseError error;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError || !document.isObject())
|
||||
return QString();
|
||||
|
||||
return document.object().value(key).toVariant().toString();
|
||||
if (isMutableKey(key))
|
||||
return stateValue(key);
|
||||
return m_productConfig.value(key).toVariant().toString();
|
||||
}
|
||||
|
||||
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
|
||||
@@ -82,100 +254,388 @@ bool ConfigHelper::setValue(const QString& section, const QString& key, const QS
|
||||
Q_UNUSED(section);
|
||||
m_error.clear();
|
||||
|
||||
QFile input(m_configPath);
|
||||
QJsonObject config;
|
||||
if (input.exists())
|
||||
if (!isMutableKey(key))
|
||||
{
|
||||
if (!input.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
|
||||
return false;
|
||||
}
|
||||
QJsonParseError parseError;
|
||||
const QByteArray raw = input.readAll();
|
||||
input.close();
|
||||
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
config = document.object();
|
||||
}
|
||||
|
||||
config.insert(key, value);
|
||||
QDir dir(QFileInfo(m_configPath).path());
|
||||
if (!dir.exists() && !dir.mkpath("."))
|
||||
{
|
||||
m_error = QString("Cannot create config directory: %1").arg(dir.path());
|
||||
m_error = QStringLiteral("Configuration key '%1' is immutable and cannot be changed at runtime.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
QSaveFile output(m_configPath);
|
||||
if (!output.open(QIODevice::WriteOnly))
|
||||
if (m_statePrefix.isEmpty())
|
||||
{
|
||||
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
|
||||
m_error = QStringLiteral("Runtime state is unavailable because product configuration was not loaded.");
|
||||
return false;
|
||||
}
|
||||
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||
if (output.write(payload) != payload.size())
|
||||
|
||||
QString normalizedValue;
|
||||
if (!normalizeMutableStateValue(key, value, &normalizedValue))
|
||||
{
|
||||
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
|
||||
output.cancelWriting();
|
||||
m_error = QStringLiteral("Runtime state key '%1' has an invalid value.").arg(key);
|
||||
return false;
|
||||
}
|
||||
if (!output.commit())
|
||||
|
||||
return writeStateValue(key, normalizedValue);
|
||||
}
|
||||
|
||||
bool ConfigHelper::loadProductConfig()
|
||||
{
|
||||
QFile file(m_productConfigPath);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
|
||||
m_error = QStringLiteral("Cannot open embedded product configuration '%1': %2")
|
||||
.arg(m_productConfigPath, file.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
m_error = QStringLiteral("Embedded product configuration is invalid JSON at offset %1: %2")
|
||||
.arg(parseError.offset)
|
||||
.arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject config = document.object();
|
||||
const QJsonValue schemaVersion = config.value(QStringLiteral("schema_version"));
|
||||
if (!schemaVersion.isDouble() || schemaVersion.toDouble(-1.0) != 1.0)
|
||||
{
|
||||
m_error = QStringLiteral("Embedded product configuration schema_version must be 1.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList requiredTextKeys{
|
||||
QStringLiteral("app_id"),
|
||||
QStringLiteral("app_name"),
|
||||
QStringLiteral("channel"),
|
||||
QStringLiteral("launch_token"),
|
||||
QStringLiteral("api_base_url"),
|
||||
QStringLiteral("client_token"),
|
||||
QStringLiteral("temp_folder"),
|
||||
QStringLiteral("install_root"),
|
||||
QStringLiteral("main_executable"),
|
||||
QStringLiteral("launcher_executable"),
|
||||
QStringLiteral("updater_executable"),
|
||||
QStringLiteral("bootstrap_executable"),
|
||||
QStringLiteral("platform"),
|
||||
QStringLiteral("arch")
|
||||
};
|
||||
for (const QString& key : requiredTextKeys)
|
||||
{
|
||||
const QJsonValue value = config.value(key);
|
||||
if (!value.isString() || value.toString().trimmed().isEmpty())
|
||||
{
|
||||
m_error = QStringLiteral("Embedded product configuration key '%1' must be a non-empty string.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const QString& key : mutableKeys())
|
||||
{
|
||||
if (config.contains(key))
|
||||
{
|
||||
m_error = QStringLiteral("Mutable key '%1' must not appear in the product configuration root.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSafeIdentifier(config.value(QStringLiteral("app_id")).toString())
|
||||
|| !isSafeIdentifier(config.value(QStringLiteral("channel")).toString()))
|
||||
{
|
||||
m_error = QStringLiteral("Product app_id and channel may contain only letters, digits, '.', '_' and '-'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QUrl apiUrl(config.value(QStringLiteral("api_base_url")).toString());
|
||||
if (!apiUrl.isValid()
|
||||
|| apiUrl.host().isEmpty()
|
||||
|| (apiUrl.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) != 0
|
||||
&& apiUrl.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) != 0))
|
||||
{
|
||||
m_error = QStringLiteral("Product api_base_url must be a valid HTTP or HTTPS URL.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList positiveIntegerKeys{
|
||||
QStringLiteral("client_protocol"),
|
||||
QStringLiteral("request_timeout_ms"),
|
||||
QStringLiteral("health_check_timeout_ms")
|
||||
};
|
||||
for (const QString& key : positiveIntegerKeys)
|
||||
{
|
||||
if (!isPositiveInteger(config.value(key)))
|
||||
{
|
||||
m_error = QStringLiteral("Embedded product configuration key '%1' must be a positive integer.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const QString installRoot = QDir::fromNativeSeparators(
|
||||
config.value(QStringLiteral("install_root")).toString().trimmed());
|
||||
if (QDir::isAbsolutePath(installRoot)
|
||||
|| installRoot.contains(QLatin1Char(':'))
|
||||
|| QDir::cleanPath(installRoot) != installRoot)
|
||||
{
|
||||
m_error = QStringLiteral("Product install_root must be a normalized relative path.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSafeIdentifier(config.value(QStringLiteral("platform")).toString())
|
||||
|| !isSafeIdentifier(config.value(QStringLiteral("arch")).toString()))
|
||||
{
|
||||
m_error = QStringLiteral("Product platform and arch may contain only letters, digits, '.', '_' and '-'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isControlledMainExecutablePath(
|
||||
config.value(QStringLiteral("main_executable")).toString(), installRoot))
|
||||
{
|
||||
m_error = QStringLiteral(
|
||||
"Embedded product configuration key 'main_executable' must stay within install_root.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QStringList restrictedRelativePaths{
|
||||
QStringLiteral("temp_folder"),
|
||||
QStringLiteral("launcher_executable"),
|
||||
QStringLiteral("updater_executable"),
|
||||
QStringLiteral("bootstrap_executable")
|
||||
};
|
||||
for (const QString& key : restrictedRelativePaths)
|
||||
{
|
||||
if (!isStrictRelativePath(config.value(key).toString()))
|
||||
{
|
||||
m_error = QStringLiteral("Embedded product configuration key '%1' must be a safe relative path.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonValue initialStateValue = config.value(QStringLiteral("initial_state"));
|
||||
if (!initialStateValue.isObject())
|
||||
{
|
||||
m_error = QStringLiteral("Embedded product configuration must contain an initial_state object.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject initialState = initialStateValue.toObject();
|
||||
QJsonObject normalizedInitialState;
|
||||
for (auto it = initialState.constBegin(); it != initialState.constEnd(); ++it)
|
||||
{
|
||||
if (!isMutableKey(it.key()) || !it.value().isString())
|
||||
{
|
||||
m_error = QStringLiteral("Initial state key '%1' is unsupported or is not a string.").arg(it.key());
|
||||
return false;
|
||||
}
|
||||
|
||||
QString normalizedValue;
|
||||
if (!normalizeMutableStateValue(it.key(), it.value().toString(), &normalizedValue))
|
||||
{
|
||||
m_error = QStringLiteral("Initial state key '%1' has an invalid value.").arg(it.key());
|
||||
return false;
|
||||
}
|
||||
normalizedInitialState.insert(it.key(), normalizedValue);
|
||||
}
|
||||
if (!normalizedInitialState.contains(QStringLiteral("current_version")))
|
||||
{
|
||||
m_error = QStringLiteral("Initial state current_version must be a non-empty string.");
|
||||
return false;
|
||||
}
|
||||
|
||||
m_productConfig = config;
|
||||
m_productConfig.remove(QStringLiteral("initial_state"));
|
||||
m_initialState = normalizedInitialState;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigHelper::migrateLegacyStateIfNeeded()
|
||||
{
|
||||
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
|
||||
QString::fromLatin1(kSettingsOrganization),
|
||||
QString::fromLatin1(kSettingsApplication));
|
||||
settings.setFallbacksEnabled(false);
|
||||
const QString markerKey = m_statePrefix + QString::fromLatin1(kMigrationMarker);
|
||||
if (settings.value(markerKey).toInt() >= kMigrationVersion)
|
||||
return true;
|
||||
|
||||
bool allStateKeysPresent = true;
|
||||
for (const QString& key : legacyMigratableKeys())
|
||||
allStateKeysPresent = allStateKeysPresent && settings.contains(stateStorageKey(key));
|
||||
|
||||
QJsonObject iniValues;
|
||||
QJsonObject jsonValues;
|
||||
const QString legacyIniPath = QDir(QCoreApplication::applicationDirPath())
|
||||
.filePath(QStringLiteral("client.ini"));
|
||||
if (!allStateKeysPresent && QFile::exists(legacyIniPath))
|
||||
{
|
||||
QSettings ini(legacyIniPath, QSettings::IniFormat);
|
||||
ini.setFallbacksEnabled(false);
|
||||
const auto importIniValue = [&](const QString& section, const QString& key) {
|
||||
const QString legacyKey = section + QLatin1Char('/') + key;
|
||||
if (ini.contains(legacyKey))
|
||||
iniValues.insert(key, ini.value(legacyKey).toString());
|
||||
};
|
||||
importIniValue(QStringLiteral("License"), QStringLiteral("license_key"));
|
||||
importIniValue(QStringLiteral("Update"), QStringLiteral("device_id"));
|
||||
importIniValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
|
||||
if (ini.status() != QSettings::NoError)
|
||||
qWarning().noquote() << QStringLiteral("Cannot read legacy INI state '%1': %2")
|
||||
.arg(legacyIniPath, settingsStatusText(ini.status()));
|
||||
}
|
||||
|
||||
const QString legacyJsonPath = QDir(QCoreApplication::applicationDirPath())
|
||||
.filePath(QStringLiteral("config/app_config.json"));
|
||||
QFile legacyJson(legacyJsonPath);
|
||||
if (!allStateKeysPresent && legacyJson.exists())
|
||||
{
|
||||
if (!legacyJson.open(QIODevice::ReadOnly))
|
||||
{
|
||||
qWarning().noquote() << QStringLiteral("Cannot read legacy JSON state '%1': %2")
|
||||
.arg(legacyJsonPath, legacyJson.errorString());
|
||||
}
|
||||
else
|
||||
{
|
||||
QJsonParseError legacyParseError;
|
||||
const QJsonDocument legacyDocument = QJsonDocument::fromJson(legacyJson.readAll(), &legacyParseError);
|
||||
if (legacyParseError.error != QJsonParseError::NoError || !legacyDocument.isObject())
|
||||
{
|
||||
qWarning().noquote() << QStringLiteral("Cannot parse legacy JSON state '%1' at offset %2: %3")
|
||||
.arg(legacyJsonPath)
|
||||
.arg(legacyParseError.offset)
|
||||
.arg(legacyParseError.errorString());
|
||||
}
|
||||
else
|
||||
{
|
||||
const QJsonObject legacy = legacyDocument.object();
|
||||
for (const QString& key : legacyMigratableKeys())
|
||||
{
|
||||
const QJsonValue value = legacy.value(key);
|
||||
if (value.isString())
|
||||
jsonValues.insert(key, value.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QStringList migratedKeys;
|
||||
for (const QString& key : legacyMigratableKeys())
|
||||
{
|
||||
const QString storageKey = stateStorageKey(key);
|
||||
if (settings.contains(storageKey))
|
||||
continue;
|
||||
|
||||
QString normalizedValue;
|
||||
bool hasValue = false;
|
||||
if (jsonValues.contains(key))
|
||||
{
|
||||
hasValue = normalizeMutableStateValue(key, jsonValues.value(key).toString(), &normalizedValue);
|
||||
if (!hasValue)
|
||||
qWarning().noquote() << QStringLiteral("Rejected invalid legacy JSON state key '%1' from '%2'.")
|
||||
.arg(key, legacyJsonPath);
|
||||
}
|
||||
if (!hasValue && iniValues.contains(key))
|
||||
{
|
||||
hasValue = normalizeMutableStateValue(key, iniValues.value(key).toString(), &normalizedValue);
|
||||
if (!hasValue)
|
||||
qWarning().noquote() << QStringLiteral("Rejected invalid legacy INI state key '%1' from '%2'.")
|
||||
.arg(key, legacyIniPath);
|
||||
}
|
||||
if (hasValue)
|
||||
{
|
||||
settings.setValue(storageKey, normalizedValue);
|
||||
migratedKeys.append(key);
|
||||
}
|
||||
}
|
||||
|
||||
settings.sync();
|
||||
if (settings.status() != QSettings::NoError)
|
||||
{
|
||||
m_error = QStringLiteral("Cannot persist legacy runtime state migration in system settings: %1")
|
||||
.arg(settingsStatusText(settings.status()));
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.setValue(markerKey, kMigrationVersion);
|
||||
settings.sync();
|
||||
if (settings.status() != QSettings::NoError)
|
||||
{
|
||||
m_error = QStringLiteral("Cannot mark legacy runtime state migration complete in system settings: %1")
|
||||
.arg(settingsStatusText(settings.status()));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!migratedKeys.isEmpty())
|
||||
qInfo().noquote() << QStringLiteral("Migrated legacy runtime state keys: %1")
|
||||
.arg(migratedKeys.join(QStringLiteral(", ")));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigHelper::initializeStateDefaults()
|
||||
{
|
||||
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
|
||||
QString::fromLatin1(kSettingsOrganization),
|
||||
QString::fromLatin1(kSettingsApplication));
|
||||
settings.setFallbacksEnabled(false);
|
||||
|
||||
for (const QString& key : mutableKeys())
|
||||
{
|
||||
const QString storageKey = stateStorageKey(key);
|
||||
if (!settings.contains(storageKey) && m_initialState.contains(key))
|
||||
settings.setValue(storageKey, m_initialState.value(key).toString());
|
||||
}
|
||||
|
||||
settings.sync();
|
||||
if (settings.status() != QSettings::NoError)
|
||||
{
|
||||
m_error = QStringLiteral("Cannot initialize runtime state in system settings: %1")
|
||||
.arg(settingsStatusText(settings.status()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||
QString ConfigHelper::stateStorageKey(const QString& key) const
|
||||
{
|
||||
if (QFile::exists(m_configPath))
|
||||
return true;
|
||||
|
||||
const QString legacyPath = QApplication::applicationDirPath() + "/client.ini";
|
||||
if (!QFile::exists(legacyPath))
|
||||
return false;
|
||||
|
||||
QSettings ini(legacyPath, QSettings::IniFormat);
|
||||
QJsonObject config;
|
||||
const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) {
|
||||
const QString value = ini.value(section + "/" + key, fallback).toString();
|
||||
config.insert(key, value);
|
||||
};
|
||||
|
||||
copyText("App", "app_id");
|
||||
copyText("App", "app_name", "Marsco Demo App");
|
||||
copyText("App", "channel", "stable");
|
||||
copyText("App", "current_version", "1.0.0");
|
||||
copyText("App", "client_protocol", "3");
|
||||
copyText("App", "launch_token");
|
||||
copyText("License", "license_key");
|
||||
copyText("Server", "api_base_url");
|
||||
copyText("Server", "client_token");
|
||||
copyText("Update", "request_timeout_ms", "5000");
|
||||
copyText("Update", "temp_folder", "update_temp");
|
||||
copyText("Update", "device_id");
|
||||
copyText("Runtime", "install_root", ".");
|
||||
copyText("Runtime", "main_executable", "MainApp.exe");
|
||||
copyText("Runtime", "launcher_executable", "Launcher.exe");
|
||||
copyText("Runtime", "updater_executable", "Updater.exe");
|
||||
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
|
||||
copyText("Runtime", "health_check_timeout_ms", "15000");
|
||||
config.insert("platform", "windows");
|
||||
config.insert("arch", "x64");
|
||||
|
||||
QDir().mkpath(QFileInfo(m_configPath).path());
|
||||
QSaveFile output(m_configPath);
|
||||
if (!output.open(QIODevice::WriteOnly))
|
||||
return false;
|
||||
output.write(QJsonDocument(config).toJson(QJsonDocument::Indented));
|
||||
const bool saved = output.commit();
|
||||
if (saved)
|
||||
qDebug() << "Migrated legacy client.ini to" << m_configPath;
|
||||
return saved;
|
||||
return m_statePrefix + QStringLiteral("state/") + key;
|
||||
}
|
||||
|
||||
QString ConfigHelper::stateValue(const QString& key) const
|
||||
{
|
||||
if (m_statePrefix.isEmpty())
|
||||
return QString();
|
||||
|
||||
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
|
||||
QString::fromLatin1(kSettingsOrganization),
|
||||
QString::fromLatin1(kSettingsApplication));
|
||||
settings.setFallbacksEnabled(false);
|
||||
const QString storageKey = stateStorageKey(key);
|
||||
if (settings.contains(storageKey))
|
||||
return settings.value(storageKey).toString();
|
||||
return QString();
|
||||
}
|
||||
|
||||
bool ConfigHelper::writeStateValue(const QString& key, const QString& value)
|
||||
{
|
||||
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
|
||||
QString::fromLatin1(kSettingsOrganization),
|
||||
QString::fromLatin1(kSettingsApplication));
|
||||
settings.setFallbacksEnabled(false);
|
||||
settings.setValue(stateStorageKey(key), value);
|
||||
settings.sync();
|
||||
if (settings.status() != QSettings::NoError)
|
||||
{
|
||||
m_error = QStringLiteral("Cannot write runtime state key '%1' to system settings: %2")
|
||||
.arg(key, settingsStatusText(settings.status()));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigHelper::isMutableKey(const QString& key)
|
||||
{
|
||||
return mutableKeys().contains(key);
|
||||
}
|
||||
|
||||
+15
-2
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
class ConfigHelper
|
||||
@@ -8,6 +9,8 @@ public:
|
||||
QString getValue(const QString& section, const QString& key) const;
|
||||
bool setValue(const QString& section, const QString& key, const QString& value);
|
||||
QString configPath() const;
|
||||
QString productConfigPath() const;
|
||||
QString stateLocation() const;
|
||||
QString installRoot() const;
|
||||
QString runtimeRoot() const;
|
||||
QString updateRoot() const;
|
||||
@@ -16,7 +19,17 @@ public:
|
||||
|
||||
private:
|
||||
ConfigHelper();
|
||||
bool migrateLegacyIniIfNeeded();
|
||||
QString m_configPath;
|
||||
bool loadProductConfig();
|
||||
bool migrateLegacyStateIfNeeded();
|
||||
bool initializeStateDefaults();
|
||||
QString stateStorageKey(const QString& key) const;
|
||||
QString stateValue(const QString& key) const;
|
||||
bool writeStateValue(const QString& key, const QString& value);
|
||||
static bool isMutableKey(const QString& key);
|
||||
|
||||
QString m_productConfigPath;
|
||||
QString m_statePrefix;
|
||||
QJsonObject m_productConfig;
|
||||
QJsonObject m_initialState;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
+341
-24
@@ -1,10 +1,13 @@
|
||||
#include "DeviceIdentityHelper.h"
|
||||
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
@@ -12,38 +15,352 @@
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QSysInfo>
|
||||
#include <QUuid>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
|
||||
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
|
||||
QString DeviceIdentityHelper::errorString() const{return m_error;}
|
||||
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
|
||||
|
||||
namespace
|
||||
{
|
||||
QString machineHash(const QString& installationId)
|
||||
{
|
||||
const QByteArray identity =
|
||||
QSysInfo::machineUniqueId() + installationId.toUtf8();
|
||||
return QString::fromLatin1(
|
||||
QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex());
|
||||
}
|
||||
}
|
||||
|
||||
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(sig64);m_error="OpenSSL unavailable";return false;
|
||||
Q_UNUSED(payload);
|
||||
Q_UNUSED(signatureBase64);
|
||||
m_error = "OpenSSL unavailable";
|
||||
return false;
|
||||
#else
|
||||
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
|
||||
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
|
||||
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
|
||||
QFile keyFile(
|
||||
QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = "embedded device public key missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio
|
||||
? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)
|
||||
: nullptr;
|
||||
if (bio)
|
||||
BIO_free(bio);
|
||||
if (!key)
|
||||
{
|
||||
m_error = "device public key invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
const QByteArray signature =
|
||||
QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
const bool valid =
|
||||
context
|
||||
&& EVP_DigestVerifyInit(
|
||||
context, nullptr, EVP_sha256(), nullptr, key)
|
||||
== 1
|
||||
&& EVP_DigestVerifyUpdate(
|
||||
context, payload.constData(), payload.size())
|
||||
== 1
|
||||
&& EVP_DigestVerifyFinal(
|
||||
context,
|
||||
reinterpret_cast<const unsigned char*>(signature.constData()),
|
||||
signature.size())
|
||||
== 1;
|
||||
|
||||
if (context)
|
||||
EVP_MD_CTX_free(context);
|
||||
EVP_PKEY_free(key);
|
||||
if (!valid)
|
||||
m_error = "device credential RSA signature invalid";
|
||||
return valid;
|
||||
#endif
|
||||
}
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
|
||||
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
|
||||
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& expectedAppId,
|
||||
const QString& expectedChannel)
|
||||
{
|
||||
QFile credential(
|
||||
QDir(m_installDir).filePath("config/client_identity.dat"));
|
||||
if (!credential.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = "device credential is missing or unreadable";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError wrapperError;
|
||||
const QJsonDocument wrapperDocument =
|
||||
QJsonDocument::fromJson(credential.readAll(), &wrapperError);
|
||||
if (wrapperError.error != QJsonParseError::NoError
|
||||
|| !wrapperDocument.isObject())
|
||||
{
|
||||
m_error = "device credential JSON invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject wrapper = wrapperDocument.object();
|
||||
const QByteArray identityText =
|
||||
wrapper.value("identity_text").toString().toUtf8();
|
||||
if (identityText.isEmpty()
|
||||
|| !verifySignature(
|
||||
identityText, wrapper.value("signature").toString()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError identityError;
|
||||
const QJsonDocument identityDocument =
|
||||
QJsonDocument::fromJson(identityText, &identityError);
|
||||
if (identityError.error != QJsonParseError::NoError
|
||||
|| !identityDocument.isObject())
|
||||
{
|
||||
m_error = "signed device identity JSON invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject identity = identityDocument.object();
|
||||
const QString signedInstallationId =
|
||||
identity.value("installation_id").toString();
|
||||
const QString signedDeviceId = identity.value("device_id").toString();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
const QString localInstallationId =
|
||||
config.getValue("Device", "installation_id").trimmed();
|
||||
const QString localDeviceId =
|
||||
config.getValue("Update", "device_id").trimmed();
|
||||
|
||||
if (identity.value("app_id").toString() != expectedAppId
|
||||
|| identity.value("channel").toString() != expectedChannel
|
||||
|| identity.value("license_id").toString().isEmpty()
|
||||
|| signedInstallationId.isEmpty() || signedDeviceId.isEmpty())
|
||||
{
|
||||
m_error = "device/license credential identity mismatch";
|
||||
return false;
|
||||
}
|
||||
if (localInstallationId.isEmpty()
|
||||
|| signedInstallationId != localInstallationId)
|
||||
{
|
||||
m_error = "device credential belongs to another installation";
|
||||
return false;
|
||||
}
|
||||
if (!localDeviceId.isEmpty() && signedDeviceId != localDeviceId)
|
||||
{
|
||||
m_error = "device credential does not match the registered device";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString signedMachineHash =
|
||||
identity.value("machine_hash").toString();
|
||||
if (!signedMachineHash.isEmpty()
|
||||
&& signedMachineHash.compare(
|
||||
machineHash(localInstallationId), Qt::CaseInsensitive)
|
||||
!= 0)
|
||||
{
|
||||
m_error = "device credential belongs to another machine";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QDateTime expiry = QDateTime::fromString(
|
||||
identity.value("valid_until").toString(), Qt::ISODate);
|
||||
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc())
|
||||
{
|
||||
m_error = "license expired";
|
||||
return false;
|
||||
}
|
||||
|
||||
m_deviceId = signedDeviceId;
|
||||
return true;
|
||||
}
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
|
||||
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
|
||||
const QString trimmedBase=base.trimmed();
|
||||
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
|
||||
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
|
||||
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
|
||||
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
|
||||
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
|
||||
ConfigHelper& config=ConfigHelper::instance();
|
||||
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
|
||||
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
|
||||
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
|
||||
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,
|
||||
const QString& channel)
|
||||
{
|
||||
m_error.clear();
|
||||
m_deviceId.clear();
|
||||
return loadAndVerify(appId, channel);
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& apiBaseUrl,
|
||||
const QString& clientToken,
|
||||
const QString& appId,
|
||||
const QString& channel,
|
||||
const QString& licenseKey)
|
||||
{
|
||||
m_error.clear();
|
||||
m_deviceId.clear();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
if (loadAndVerify(appId, channel))
|
||||
{
|
||||
if (!config.setValue("Update", "device_id", m_deviceId))
|
||||
{
|
||||
m_error = QString("cannot save server device id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString server = apiBaseUrl.trimmed();
|
||||
if (appId.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "app_id is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (channel.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "channel is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (server.isEmpty()
|
||||
|| server.contains("YOUR_SERVER_IP", Qt::CaseInsensitive))
|
||||
{
|
||||
m_error = "api_base_url is not configured";
|
||||
return false;
|
||||
}
|
||||
if (clientToken.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "client_token is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (licenseKey.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "license_key is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString installationId =
|
||||
config.getValue("Device", "installation_id").trimmed();
|
||||
if (installationId.isEmpty())
|
||||
{
|
||||
installationId =
|
||||
QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
if (!config.setValue("Device", "installation_id", installationId))
|
||||
{
|
||||
m_error = QString("cannot save installation id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonObject requestBody{
|
||||
{"app_id", appId},
|
||||
{"channel", channel},
|
||||
{"license_key", licenseKey},
|
||||
{"installation_id", installationId},
|
||||
{"machine_hash", machineHash(installationId)}};
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkRequest request{QUrl(server + "/api/v1/device/issue")};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||
QNetworkReply* reply = manager.post(
|
||||
request, QJsonDocument(requestBody).toJson(QJsonDocument::Compact));
|
||||
|
||||
QEventLoop loop;
|
||||
bool timeoutValid = false;
|
||||
int timeoutMs =
|
||||
config.getValue("Update", "request_timeout_ms").toInt(&timeoutValid);
|
||||
if (!timeoutValid || timeoutMs < 1000)
|
||||
timeoutMs = 5000;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
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 QByteArray responseBytes = reply->readAll();
|
||||
const QString networkError = reply->errorString();
|
||||
reply->deleteLater();
|
||||
if (status != 200)
|
||||
{
|
||||
m_error = QString("device issue failed (HTTP %1): %2 %3")
|
||||
.arg(status)
|
||||
.arg(networkError, QString::fromUtf8(responseBytes));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError responseError;
|
||||
const QJsonDocument responseDocument =
|
||||
QJsonDocument::fromJson(responseBytes, &responseError);
|
||||
if (responseError.error != QJsonParseError::NoError
|
||||
|| !responseDocument.isObject())
|
||||
{
|
||||
m_error = "device issue response is invalid JSON";
|
||||
return false;
|
||||
}
|
||||
const QJsonObject response = responseDocument.object();
|
||||
const QJsonObject wrapper{
|
||||
{"identity_text", response.value("identity_text")},
|
||||
{"signature", response.value("signature")}};
|
||||
if (!wrapper.value("identity_text").isString()
|
||||
|| wrapper.value("identity_text").toString().isEmpty()
|
||||
|| !wrapper.value("signature").isString()
|
||||
|| wrapper.value("signature").toString().isEmpty())
|
||||
{
|
||||
m_error = "device issue response is missing signed identity fields";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString credentialPath =
|
||||
QDir(m_installDir).filePath("config/client_identity.dat");
|
||||
if (!QDir().mkpath(QFileInfo(credentialPath).path()))
|
||||
{
|
||||
m_error = "cannot create the device credential directory";
|
||||
return false;
|
||||
}
|
||||
QSaveFile output(credentialPath);
|
||||
const QByteArray credentialBytes =
|
||||
QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
if (!output.open(QIODevice::WriteOnly)
|
||||
|| output.write(credentialBytes) != credentialBytes.size()
|
||||
|| !output.commit())
|
||||
{
|
||||
m_error = QString("cannot save device credential to %1: %2")
|
||||
.arg(credentialPath, output.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!loadAndVerify(appId, channel))
|
||||
return false;
|
||||
if (!config.setValue("Update", "device_id", m_deviceId))
|
||||
{
|
||||
m_error = QString("cannot save server device id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
||||
{
|
||||
if (!QFile::remove(dst))
|
||||
{
|
||||
qDebug() << "无法删除旧文件:" << dst;
|
||||
qDebug() << "Cannot remove the old file:" << dst;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -39,4 +39,4 @@ bool FileHelper::killProcess(const QString &exeName)
|
||||
process.start("taskkill /f /im " + exeName);
|
||||
process.waitForFinished(1000);
|
||||
return !isProcessRunning(exeName);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -11,7 +11,7 @@
|
||||
class HttpHelper
|
||||
{
|
||||
public:
|
||||
// 每次调用独立创建manager,不做成成员变量
|
||||
// Each request owns its manager so synchronous calls cannot share reply state.
|
||||
static void postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,13 +30,13 @@ bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||
{
|
||||
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
QSet<QString> protectedPaths{
|
||||
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"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.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"config/client_identity.dat", "config/version_policy.dat"
|
||||
};
|
||||
for (const QString& protectedPath : runtimeProtected)
|
||||
@@ -59,11 +59,8 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||
#else
|
||||
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
|
||||
if (!QFile::exists(keyPath))
|
||||
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
|
||||
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "embedded manifest public key missing"; return false; }
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||
|
||||
+46
-6
@@ -22,12 +22,35 @@ bool PolicyHelper::loadPolicy(const QString& relativePath)
|
||||
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
m_error = "Invalid policy JSON: " + error.errorString(); return false;
|
||||
}
|
||||
return loadPolicyObject(doc.object());
|
||||
const QJsonObject stored = doc.object();
|
||||
if (stored.value("policy").isObject() && stored.value("policy_text").isString())
|
||||
return loadPolicyObject(stored.value("policy").toObject(),
|
||||
stored.value("policy_text").toString());
|
||||
return loadPolicyObject(stored);
|
||||
}
|
||||
|
||||
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
|
||||
{
|
||||
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
|
||||
m_error.clear();
|
||||
m_signedText = signedText;
|
||||
m_policy = policy;
|
||||
if (!signedText.isEmpty())
|
||||
{
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument signedDocument =
|
||||
QJsonDocument::fromJson(signedText.toUtf8(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !signedDocument.isObject())
|
||||
{
|
||||
m_error = "Signed policy text is invalid JSON: " + parseError.errorString();
|
||||
m_loaded = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString signature = policy.value("signature").toString();
|
||||
m_policy = signedDocument.object();
|
||||
m_policy.insert("signature", signature);
|
||||
}
|
||||
m_loaded = true;
|
||||
return isValid();
|
||||
}
|
||||
|
||||
@@ -35,7 +58,12 @@ bool PolicyHelper::savePolicy(const QString& relativePath) const
|
||||
{
|
||||
QSaveFile file(m_baseDir + "/" + relativePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) return false;
|
||||
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
|
||||
QJsonObject stored = m_policy;
|
||||
if (!m_signedText.isEmpty())
|
||||
{
|
||||
stored = QJsonObject{{"policy", m_policy}, {"policy_text", m_signedText}};
|
||||
}
|
||||
const QByteArray bytes = QJsonDocument(stored).toJson(QJsonDocument::Indented);
|
||||
return file.write(bytes) == bytes.size() && file.commit();
|
||||
}
|
||||
|
||||
@@ -68,9 +96,8 @@ bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& sig
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||
#else
|
||||
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; }
|
||||
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open embedded policy public key"; return false; }
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||
@@ -97,6 +124,19 @@ bool PolicyHelper::isValid() const
|
||||
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
|
||||
return verifySignature(payload, m_policy.value("signature").toString());
|
||||
}
|
||||
bool PolicyHelper::isApplicable(const QString& appId, const QString& channel,
|
||||
const QString& currentVersion) const
|
||||
{
|
||||
if (!isValid()) return false;
|
||||
if (m_policy.value("app_id").toString() != appId
|
||||
|| m_policy.value("channel").toString() != channel
|
||||
|| m_policy.value("current_version").toString() != currentVersion)
|
||||
{
|
||||
m_error = "Policy identity does not match this installation";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
QString PolicyHelper::errorString() const { return m_error; }
|
||||
bool PolicyHelper::isVersionAllowed(const QString& version) const {
|
||||
if (!isValid()) return false;
|
||||
|
||||
@@ -11,6 +11,8 @@ public:
|
||||
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
|
||||
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
|
||||
bool isValid() const;
|
||||
bool isApplicable(const QString& appId, const QString& channel,
|
||||
const QString& currentVersion) const;
|
||||
QString errorString() const;
|
||||
bool isVersionAllowed(const QString& currentVersion) const;
|
||||
bool allowRun() const;
|
||||
|
||||
+58
-5
@@ -3,6 +3,7 @@
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageAuthenticationCode>
|
||||
@@ -16,11 +17,57 @@ static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
static bool constantTimeEqual(const QByteArray& left, const QByteArray& right)
|
||||
{
|
||||
if (left.size() != right.size()) return false;
|
||||
unsigned char difference = 0;
|
||||
for (qsizetype index = 0; index < left.size(); ++index)
|
||||
difference |= static_cast<unsigned char>(left.at(index) ^ right.at(index));
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool claimTicketNonce(const QString& appId, const QString& nonce, QString* errorMessage)
|
||||
{
|
||||
const QString claimsRoot = QDir(QStandardPaths::writableLocation(
|
||||
QStandardPaths::AppLocalDataLocation)).filePath("launcher-ticket-claims");
|
||||
if (!QDir().mkpath(claimsRoot)) {
|
||||
if (errorMessage) *errorMessage = "cannot create ticket replay state";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir claimsDirectory(claimsRoot);
|
||||
const QDateTime staleBefore = QDateTime::currentDateTimeUtc().addSecs(-600);
|
||||
const QFileInfoList oldClaims = claimsDirectory.entryInfoList(
|
||||
QStringList{"*.claimed"}, QDir::Files);
|
||||
for (const QFileInfo& claim : oldClaims)
|
||||
if (claim.lastModified().toUTC() < staleBefore) QFile::remove(claim.absoluteFilePath());
|
||||
|
||||
const QByteArray claimIdentity = appId.toUtf8() + '\0' + nonce.toUtf8();
|
||||
const QString claimName = QString::fromLatin1(
|
||||
QCryptographicHash::hash(claimIdentity, QCryptographicHash::Sha256).toHex()) + ".claimed";
|
||||
QFile claimFile(claimsDirectory.filePath(claimName));
|
||||
if (!claimFile.open(QIODevice::WriteOnly | QIODevice::NewOnly)) {
|
||||
if (errorMessage) *errorMessage = "ticket nonce was already consumed";
|
||||
return false;
|
||||
}
|
||||
const QByteArray marker = QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toUtf8();
|
||||
if (claimFile.write(marker) != marker.size()) {
|
||||
const QString claimPath = claimFile.fileName();
|
||||
claimFile.close();
|
||||
QFile::remove(claimPath);
|
||||
if (errorMessage) *errorMessage = "cannot persist ticket replay state";
|
||||
return false;
|
||||
}
|
||||
claimFile.close();
|
||||
QFile::setPermissions(claimFile.fileName(), QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
const QString& version, const QString& secret,
|
||||
QString* ticketPath, QString* errorMessage)
|
||||
{
|
||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
||||
return false;
|
||||
}
|
||||
@@ -51,6 +98,11 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage)
|
||||
{
|
||||
if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty()
|
||||
|| expectedVersion.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete";
|
||||
return false;
|
||||
}
|
||||
const QString consumingPath = ticketPath + ".consuming."
|
||||
+ QString::number(QCoreApplication::applicationPid());
|
||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||
@@ -64,7 +116,7 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
return false;
|
||||
}
|
||||
const QByteArray raw = file.readAll(); file.close();
|
||||
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。
|
||||
QFile::remove(consumingPath); // Consume once so neither successful nor failed claims can be replayed.
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
@@ -82,10 +134,11 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
&& payload.value("version").toString() == expectedVersion;
|
||||
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk
|
||||
|| payload.value("nonce").toString().isEmpty()) {
|
||||
const QString nonce = payload.value("nonce").toString();
|
||||
if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk
|
||||
|| nonce.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return claimTicketNonce(expectedAppId, nonce, errorMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "TranslationHelper.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr auto kSimplifiedChineseCatalog =
|
||||
":/simcae/update-client/i18n/update-client_zh_CN.qm";
|
||||
constexpr auto kQtSimplifiedChineseCatalog = "translations/qt_zh_CN.qm";
|
||||
|
||||
bool usesSimplifiedChinese(const QLocale& locale)
|
||||
{
|
||||
if (locale.language() != QLocale::Chinese ||
|
||||
locale.script() == QLocale::TraditionalHanScript)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return locale.script() == QLocale::SimplifiedHanScript ||
|
||||
locale.country() == QLocale::China ||
|
||||
locale.country() == QLocale::Singapore;
|
||||
}
|
||||
}
|
||||
|
||||
TranslationHelper::TranslationHelper()
|
||||
{
|
||||
m_simplifiedChineseCatalog.load(
|
||||
QString::fromLatin1(kSimplifiedChineseCatalog));
|
||||
}
|
||||
|
||||
bool TranslationHelper::installForSystemLocale()
|
||||
{
|
||||
if (!usesSimplifiedChinese(QLocale::system()) ||
|
||||
m_simplifiedChineseCatalog.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString qtCatalogPath = QDir(QCoreApplication::applicationDirPath())
|
||||
.filePath(QString::fromLatin1(
|
||||
kQtSimplifiedChineseCatalog));
|
||||
if (m_qtSimplifiedChineseCatalog.load(qtCatalogPath))
|
||||
QCoreApplication::installTranslator(&m_qtSimplifiedChineseCatalog);
|
||||
|
||||
return QCoreApplication::installTranslator(&m_simplifiedChineseCatalog);
|
||||
}
|
||||
|
||||
QString TranslationHelper::translate(const char* context,
|
||||
const char* sourceText) const
|
||||
{
|
||||
return m_simplifiedChineseCatalog.translate(context, sourceText);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLocale>
|
||||
#include <QString>
|
||||
#include <QTranslator>
|
||||
|
||||
class TranslationHelper final
|
||||
{
|
||||
public:
|
||||
TranslationHelper();
|
||||
|
||||
bool installForSystemLocale();
|
||||
QString translate(const char* context, const char* sourceText) const;
|
||||
|
||||
private:
|
||||
QTranslator m_qtSimplifiedChineseCatalog;
|
||||
QTranslator m_simplifiedChineseCatalog;
|
||||
};
|
||||
Reference in New Issue
Block a user