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:
@@ -59,6 +59,8 @@ dist/
|
||||
*.rar
|
||||
|
||||
# Local runtime state and credentials
|
||||
client.ini
|
||||
config/config.json
|
||||
config/app_config.json
|
||||
config/client_identity.dat
|
||||
config/local_state.json
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
project(Bootstrap LANGUAGES CXX)
|
||||
set(_bootstrap_sources main.cpp)
|
||||
if(WIN32)
|
||||
file(GLOB _bootstrap_resource_files CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.rc")
|
||||
list(APPEND _bootstrap_sources ${_bootstrap_resource_files})
|
||||
endif()
|
||||
|
||||
add_executable(Bootstrap WIN32 main.cpp)
|
||||
add_executable(Bootstrap ${_bootstrap_sources})
|
||||
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
||||
set_target_properties(Bootstrap PROPERTIES
|
||||
AUTOMOC OFF
|
||||
AUTOUIC OFF
|
||||
AUTORCC OFF
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
|
||||
target_link_libraries(Bootstrap PRIVATE shell32)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma code_page(65001)
|
||||
|
||||
#include <windows.h>
|
||||
#include "resource.h"
|
||||
|
||||
LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL
|
||||
|
||||
STRINGTABLE
|
||||
BEGIN
|
||||
IDS_BOOTSTRAP_ERROR_TITLE "Update handoff failed"
|
||||
IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE "Bootstrap arguments are incomplete."
|
||||
IDS_BOOTSTRAP_INVALID_PLAN_OPERATION "The update plan contains an invalid operation."
|
||||
IDS_BOOTSTRAP_UNSAFE_PLAN_PATH "The update plan contains an unsafe path."
|
||||
IDS_BOOTSTRAP_RESTART_UPDATER_FAILED "Updater.exe could not be restarted."
|
||||
IDS_BOOTSTRAP_FAILED_FILE_PREFIX "Failed file: "
|
||||
END
|
||||
|
||||
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
|
||||
|
||||
STRINGTABLE
|
||||
BEGIN
|
||||
IDS_BOOTSTRAP_ERROR_TITLE "更新接管失败"
|
||||
IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE "Bootstrap 参数不完整。"
|
||||
IDS_BOOTSTRAP_INVALID_PLAN_OPERATION "更新计划操作格式无效。"
|
||||
IDS_BOOTSTRAP_UNSAFE_PLAN_PATH "更新计划包含不安全路径。"
|
||||
IDS_BOOTSTRAP_RESTART_UPDATER_FAILED "无法重新启动 Updater.exe。"
|
||||
IDS_BOOTSTRAP_FAILED_FILE_PREFIX "失败文件:"
|
||||
END
|
||||
+24
-6
@@ -1,5 +1,6 @@
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include "resource.h"
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
@@ -10,6 +11,21 @@
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static std::wstring loadLocalizedString(unsigned int id)
|
||||
{
|
||||
const wchar_t* value = nullptr;
|
||||
const int length = LoadStringW(GetModuleHandleW(nullptr), id,
|
||||
reinterpret_cast<LPWSTR>(&value), 0);
|
||||
return length > 0 ? std::wstring(value, static_cast<size_t>(length)) : std::wstring();
|
||||
}
|
||||
|
||||
static void showBootstrapError(unsigned int messageId)
|
||||
{
|
||||
const std::wstring message = loadLocalizedString(messageId);
|
||||
const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
|
||||
MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
|
||||
}
|
||||
|
||||
static std::wstring quote(const std::wstring& value)
|
||||
{
|
||||
std::wstring result = L"\"";
|
||||
@@ -115,7 +131,7 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
int argc = 0;
|
||||
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
|
||||
if (!argv || argc < 11) {
|
||||
MessageBoxW(nullptr, L"Bootstrap 参数不完整。", L"更新接管失败", MB_ICONERROR);
|
||||
showBootstrapError(IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE);
|
||||
if (argv) LocalFree(argv);
|
||||
return 2;
|
||||
}
|
||||
@@ -142,12 +158,12 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
while (std::getline(input, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
|
||||
MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR);
|
||||
showBootstrapError(IDS_BOOTSTRAP_INVALID_PLAN_OPERATION);
|
||||
return 3;
|
||||
}
|
||||
const fs::path relative(fromUtf8(line.substr(2)));
|
||||
if (!safeRelative(relative)) {
|
||||
MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR);
|
||||
showBootstrapError(IDS_BOOTSTRAP_UNSAFE_PLAN_PATH);
|
||||
return 3;
|
||||
}
|
||||
items.push_back({static_cast<wchar_t>(line[0]), relative});
|
||||
@@ -182,9 +198,11 @@ int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
|
||||
const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
|
||||
if (!launchUpdater(updater, updateArgs, result)) {
|
||||
std::wstring message = L"无法重新启动 Updater.exe。";
|
||||
if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring();
|
||||
MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR);
|
||||
std::wstring message = loadLocalizedString(IDS_BOOTSTRAP_RESTART_UPDATER_FAILED);
|
||||
if (!failedPath.empty())
|
||||
message += L"\n" + loadLocalizedString(IDS_BOOTSTRAP_FAILED_FILE_PREFIX) + failedPath.wstring();
|
||||
const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
|
||||
MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
|
||||
return 4;
|
||||
}
|
||||
return (success || rolledBack) ? 0 : 5;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#define IDS_BOOTSTRAP_ERROR_TITLE 1000
|
||||
#define IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE 1001
|
||||
#define IDS_BOOTSTRAP_INVALID_PLAN_OPERATION 1002
|
||||
#define IDS_BOOTSTRAP_UNSAFE_PLAN_PATH 1003
|
||||
#define IDS_BOOTSTRAP_RESTART_UPDATER_FAILED 1004
|
||||
#define IDS_BOOTSTRAP_FAILED_FILE_PREFIX 1005
|
||||
+119
-56
@@ -1,70 +1,133 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(ClientAll LANGUAGES C CXX)
|
||||
|
||||
project(SimCAEUpdateClient LANGUAGES C CXX)
|
||||
|
||||
set(UPDATE_CLIENT_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set(UPDATE_CLIENT_IS_STANDALONE OFF)
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(UPDATE_CLIENT_IS_STANDALONE ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
if(MSVC)
|
||||
add_compile_options(/utf-8)
|
||||
endif()
|
||||
# Qt全局配置
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
||||
# ========== OpenSSL 手动配置(完全抛弃find_package) ==========
|
||||
set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64")
|
||||
set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include")
|
||||
set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd")
|
||||
set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD")
|
||||
# 全局宏,所有子项目统一启用OpenSSL
|
||||
add_compile_definitions(HAVE_OPENSSL=1)
|
||||
# ==========================================================
|
||||
# 统一输出目录
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库
|
||||
|
||||
if(MSVC)
|
||||
add_compile_options(/utf-8)
|
||||
endif()
|
||||
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_SOURCE_DIR
|
||||
BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/.."
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _update_client_source_root
|
||||
)
|
||||
cmake_path(GET _update_client_source_root PARENT_PATH _update_client_parent_root)
|
||||
set(
|
||||
UPDATE_CLIENT_THIRDPARTY_ROOT
|
||||
"${_update_client_parent_root}/3rdparty"
|
||||
CACHE PATH
|
||||
"Root directory for update-client third-party packages"
|
||||
)
|
||||
option(
|
||||
UPDATE_CLIENT_STRICT_THIRDPARTY
|
||||
"Require OpenSSL imported library and include paths to stay under the third-party root"
|
||||
ON
|
||||
)
|
||||
set(
|
||||
UPDATE_CLIENT_PRODUCT_CONFIG_FILE
|
||||
""
|
||||
CACHE FILEPATH
|
||||
"Exact product config to embed; empty selects config/config.json then the example"
|
||||
)
|
||||
set(
|
||||
UPDATE_CLIENT_LAUNCH_TOKEN
|
||||
""
|
||||
CACHE STRING
|
||||
"Optional build-provided launch ticket token override"
|
||||
)
|
||||
mark_as_advanced(UPDATE_CLIENT_LAUNCH_TOKEN)
|
||||
set(UPDATE_CLIENT_MAIN_EXECUTABLE "" CACHE STRING "Override main_executable in embedded config")
|
||||
set(UPDATE_CLIENT_LAUNCHER_EXECUTABLE "" CACHE STRING "Override launcher_executable in embedded config")
|
||||
set(UPDATE_CLIENT_UPDATER_EXECUTABLE "" CACHE STRING "Override updater_executable in embedded config")
|
||||
set(UPDATE_CLIENT_BOOTSTRAP_EXECUTABLE "" CACHE STRING "Override bootstrap_executable in embedded config")
|
||||
set(UPDATE_CLIENT_PLATFORM "" CACHE STRING "Override platform in embedded config")
|
||||
set(UPDATE_CLIENT_ARCH "" CACHE STRING "Override arch in embedded config")
|
||||
|
||||
if(WIN32)
|
||||
set(_update_client_deploy_qt_default ON)
|
||||
else()
|
||||
set(_update_client_deploy_qt_default OFF)
|
||||
endif()
|
||||
option(
|
||||
UPDATE_CLIENT_DEPLOY_QT_RUNTIME
|
||||
"Run the Qt deployment tool after building each Qt executable"
|
||||
${_update_client_deploy_qt_default}
|
||||
)
|
||||
option(
|
||||
UPDATE_CLIENT_REQUIRE_TRANSLATIONS
|
||||
"Fail configuration when translations/update-client_zh_CN.ts is absent"
|
||||
OFF
|
||||
)
|
||||
option(
|
||||
UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS
|
||||
"Install update-client runtime assets from this project"
|
||||
${UPDATE_CLIENT_IS_STANDALONE}
|
||||
)
|
||||
|
||||
list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
|
||||
include(UpdateClientDependencies)
|
||||
include(UpdateClientResources)
|
||||
|
||||
update_client_find_dependencies()
|
||||
update_client_select_product_config(_update_client_product_config_source)
|
||||
update_client_generate_normalized_product_config(
|
||||
_update_client_product_config
|
||||
"${_update_client_product_config_source}"
|
||||
)
|
||||
update_client_create_embedded_resources(
|
||||
UpdateClientEmbeddedResources
|
||||
"${_update_client_product_config}"
|
||||
)
|
||||
|
||||
add_custom_target(UpdateClientNoHanCheck ALL
|
||||
COMMAND "${CMAKE_COMMAND}"
|
||||
"-DUPDATE_CLIENT_SCAN_ROOT=${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckNoHan.cmake"
|
||||
COMMENT "Checking update-client production sources for Han characters"
|
||||
VERBATIM
|
||||
)
|
||||
add_dependencies(UpdateClientEmbeddedResources UpdateClientNoHanCheck)
|
||||
|
||||
if(UPDATE_CLIENT_IS_STANDALONE)
|
||||
if(NOT CMAKE_ARCHIVE_OUTPUT_DIRECTORY)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/out/lib")
|
||||
endif()
|
||||
if(NOT CMAKE_LIBRARY_OUTPUT_DIRECTORY)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/out/bin")
|
||||
endif()
|
||||
if(NOT CMAKE_RUNTIME_OUTPUT_DIRECTORY)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/out/bin")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_subdirectory(Bootstrap)
|
||||
# 先编译公共库
|
||||
add_subdirectory(Common)
|
||||
# 再编译三个业务程序
|
||||
add_subdirectory(Launcher)
|
||||
add_subdirectory(Updater)
|
||||
add_subdirectory(MainApp)
|
||||
# 默认启动项目
|
||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
# Copy client.ini and config directory to output bin folder
|
||||
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
|
||||
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
|
||||
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
|
||||
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
|
||||
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
|
||||
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
|
||||
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
|
||||
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
|
||||
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
|
||||
endif()
|
||||
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
|
||||
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
|
||||
configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
|
||||
endif()
|
||||
|
||||
foreach(_update_client_primary_target IN ITEMS
|
||||
Bootstrap Common Launcher Updater MainApp)
|
||||
add_dependencies("${_update_client_primary_target}" UpdateClientNoHanCheck)
|
||||
endforeach()
|
||||
# 编译阶段同步静态配置资源
|
||||
add_custom_target(copy_client_resources ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem
|
||||
COMMENT "Copy client config directory and manifest public key to output bin folder"
|
||||
)
|
||||
add_dependencies(Launcher copy_client_resources)
|
||||
add_dependencies(Updater copy_client_resources)
|
||||
add_dependencies(MainApp copy_client_resources)
|
||||
# Install rules for packaging
|
||||
if(EXISTS ${CONFIG_SOURCE_DIR})
|
||||
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config)
|
||||
endif()
|
||||
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE})
|
||||
install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin)
|
||||
|
||||
set_property(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
|
||||
if(UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS)
|
||||
update_client_install_openssl_runtime(bin)
|
||||
endif()
|
||||
+13
-18
@@ -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
|
||||
)
|
||||
+567
-107
@@ -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());
|
||||
m_error = QStringLiteral("Configuration key '%1' is immutable and cannot be changed at runtime.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_statePrefix.isEmpty())
|
||||
{
|
||||
m_error = QStringLiteral("Runtime state is unavailable because product configuration was not loaded.");
|
||||
return false;
|
||||
}
|
||||
|
||||
QString normalizedValue;
|
||||
if (!normalizeMutableStateValue(key, value, &normalizedValue))
|
||||
{
|
||||
m_error = QStringLiteral("Runtime state key '%1' has an invalid value.").arg(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return writeStateValue(key, normalizedValue);
|
||||
}
|
||||
|
||||
bool ConfigHelper::loadProductConfig()
|
||||
{
|
||||
QFile file(m_productConfigPath);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QStringLiteral("Cannot open embedded product configuration '%1': %2")
|
||||
.arg(m_productConfigPath, file.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QByteArray raw = input.readAll();
|
||||
input.close();
|
||||
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &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("Embedded product configuration is invalid JSON at offset %1: %2")
|
||||
.arg(parseError.offset)
|
||||
.arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
QSaveFile output(m_configPath);
|
||||
if (!output.open(QIODevice::WriteOnly))
|
||||
const QJsonObject config = document.object();
|
||||
const QJsonValue schemaVersion = config.value(QStringLiteral("schema_version"));
|
||||
if (!schemaVersion.isDouble() || schemaVersion.toDouble(-1.0) != 1.0)
|
||||
{
|
||||
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
|
||||
m_error = QStringLiteral("Embedded product configuration schema_version must be 1.");
|
||||
return false;
|
||||
}
|
||||
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||
if (output.write(payload) != payload.size())
|
||||
{
|
||||
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
|
||||
output.cancelWriting();
|
||||
return false;
|
||||
}
|
||||
if (!output.commit())
|
||||
{
|
||||
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||
{
|
||||
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);
|
||||
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")
|
||||
};
|
||||
|
||||
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))
|
||||
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;
|
||||
output.write(QJsonDocument(config).toJson(QJsonDocument::Indented));
|
||||
const bool saved = output.commit();
|
||||
if (saved)
|
||||
qDebug() << "Migrated legacy client.ini to" << m_configPath;
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
QString ConfigHelper::stateStorageKey(const QString& key) const
|
||||
{
|
||||
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;
|
||||
};
|
||||
|
||||
+340
-23
@@ -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;
|
||||
}
|
||||
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;}
|
||||
|
||||
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();
|
||||
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;
|
||||
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();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
};
|
||||
+26
-38
@@ -1,46 +1,34 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(LauncherDemo LANGUAGES C CXX)
|
||||
# C++标准
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
# Qt 自动处理ui/moc/rcc
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
# 依赖Qt模块:网络、基础核心、窗口
|
||||
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
||||
# 所有源码文件
|
||||
set(SRC_LIST
|
||||
set(_launcher_sources
|
||||
main.cpp
|
||||
UpdateLogic.h
|
||||
UpdateLogic.cpp
|
||||
)
|
||||
# 生成可执行程序
|
||||
add_executable(Launcher ${SRC_LIST})
|
||||
|
||||
if(WIN32)
|
||||
set(_launcher_manifest "${CMAKE_CURRENT_SOURCE_DIR}/launcher.manifest")
|
||||
if(NOT EXISTS "${_launcher_manifest}")
|
||||
message(FATAL_ERROR "Launcher UAC manifest is missing: ${_launcher_manifest}")
|
||||
endif()
|
||||
list(APPEND _launcher_sources "${_launcher_manifest}")
|
||||
endif()
|
||||
|
||||
add_executable(Launcher ${_launcher_sources})
|
||||
target_compile_features(Launcher PRIVATE cxx_std_17)
|
||||
target_link_libraries(Launcher
|
||||
PRIVATE
|
||||
Common
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
update_client_link_embedded_resources(Launcher)
|
||||
update_client_deploy_openssl_runtime(Launcher)
|
||||
update_client_enable_qt_deployment(Launcher)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
if(MSVC)
|
||||
target_link_options(Launcher PRIVATE /MANIFESTUAC:NO)
|
||||
endif()
|
||||
|
||||
# 关键:添加OpenSSL头文件目录
|
||||
target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
|
||||
|
||||
# 链接Common,自动带上OpenSSL库
|
||||
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
|
||||
# 输出编译产物到 out 文件夹(干净不乱)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||
# 强制VS打开CMake文件夹时默认选中该可执行目标
|
||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET Launcher POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Launcher>
|
||||
COMMENT "自动部署Qt依赖dll"
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
+214
-78
@@ -1,4 +1,3 @@
|
||||
#include <windows.h>
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QMessageBox>
|
||||
@@ -6,26 +5,28 @@
|
||||
#include <QProgressDialog>
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QTextCodec>
|
||||
#include "UpdateLogic.h"
|
||||
#include "../Common/ConfigHelper.h"
|
||||
#include "../Common/PolicyHelper.h"
|
||||
#include "../Common/LocalStateHelper.h"
|
||||
#include "../Common/TicketHelper.h"
|
||||
#include "../Common/DeviceIdentityHelper.h"
|
||||
#include "../Common/IntegrityHelper.h"
|
||||
#include "../Common/TranslationHelper.h"
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QDir>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
SetConsoleOutputCP(65001);
|
||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("Marsco Launcher");
|
||||
|
||||
QProgressDialog progress("正在检查软件更新...", QString(), 0, 0);
|
||||
progress.setWindowTitle("Marsco 软件启动器");
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
|
||||
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher"));
|
||||
progress.setCancelButton(nullptr);
|
||||
progress.setWindowModality(Qt::ApplicationModal);
|
||||
progress.setMinimumDuration(0);
|
||||
@@ -35,79 +36,116 @@ int main(int argc, char* argv[])
|
||||
|
||||
const QString appDir = QApplication::applicationDirPath();
|
||||
ConfigHelper &config = ConfigHelper::instance();
|
||||
const auto isLicenseError = [](const QString& errorText) {
|
||||
const auto isLicenseError = [&translation](const QString &errorText)
|
||||
{
|
||||
const QString text = errorText.toLower();
|
||||
return text.contains("license")
|
||||
|| errorText.contains("授权")
|
||||
|| errorText.contains("过期")
|
||||
|| errorText.contains("设备数量已达到上限")
|
||||
|| errorText.contains("已绑定其他授权");
|
||||
QStringList markers{QStringLiteral("license")};
|
||||
const char *const markerSources[]{
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "authorization"),
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "expired"),
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "device limit reached"),
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "bound to another license")};
|
||||
for (const char *source : markerSources)
|
||||
{
|
||||
markers.append(QCoreApplication::translate("LauncherErrorMarker", source));
|
||||
markers.append(translation.translate("LauncherErrorMarker", source));
|
||||
}
|
||||
for (const QString &marker : markers)
|
||||
{
|
||||
if (!marker.isEmpty() && text.contains(marker.toLower()))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const auto clearDeviceCredential = [&]() {
|
||||
const auto clearDeviceCredential = [&]()
|
||||
{
|
||||
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
|
||||
config.setValue("Update", "device_id", QString());
|
||||
};
|
||||
QString licenseKey = config.getValue("License", "license_key").trimmed();
|
||||
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
|
||||
auto promptAndSaveLicense = [&](const QString &reason) -> QString
|
||||
{
|
||||
QString promptReason = reason;
|
||||
while (true) {
|
||||
while (true)
|
||||
{
|
||||
progress.close();
|
||||
bool accepted = false;
|
||||
const QString appName = config.getValue("App", "app_name").trimmed();
|
||||
const QString displayName = appName.isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "application")
|
||||
: appName;
|
||||
const QString prompt = promptReason.trimmed().isEmpty()
|
||||
? QString("请输入%1授权 License:").arg(appName.isEmpty() ? "软件" : appName)
|
||||
: QString("%1\n\n请重新输入%2授权 License:").arg(promptReason, appName.isEmpty() ? "软件" : appName);
|
||||
? QCoreApplication::translate("Launcher", "Enter the license for %1:").arg(displayName)
|
||||
: QCoreApplication::translate("Launcher", "%1\n\nPlease re-enter the license for %2:").arg(promptReason, displayName);
|
||||
licenseKey = QInputDialog::getText(
|
||||
nullptr,
|
||||
"输入 License",
|
||||
QCoreApplication::translate("Launcher", "Enter License"),
|
||||
prompt,
|
||||
QLineEdit::Normal,
|
||||
QString(),
|
||||
&accepted
|
||||
).trimmed();
|
||||
if (!accepted) {
|
||||
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。");
|
||||
&accepted)
|
||||
.trimmed();
|
||||
if (!accepted)
|
||||
{
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "License Required"),
|
||||
QCoreApplication::translate("Launcher", "A license provided by an administrator is required for the first launch."));
|
||||
return QString();
|
||||
}
|
||||
if (licenseKey.isEmpty()) {
|
||||
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。");
|
||||
if (licenseKey.isEmpty())
|
||||
{
|
||||
QMessageBox::warning(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
|
||||
QCoreApplication::translate("Launcher", "Paste the license created by an administrator."));
|
||||
promptReason.clear();
|
||||
continue;
|
||||
}
|
||||
if (!config.setValue("License", "license_key", licenseKey)) {
|
||||
QMessageBox::critical(nullptr, "保存 License 失败",
|
||||
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError()));
|
||||
if (!config.setValue("License", "license_key", licenseKey))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Failed to Save License"),
|
||||
QCoreApplication::translate("Launcher", "Cannot save system state at %1:\n%2")
|
||||
.arg(config.configPath(), config.lastError()));
|
||||
return QString();
|
||||
}
|
||||
progress.show();
|
||||
progress.setLabelText("正在验证 License...");
|
||||
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying license..."));
|
||||
QApplication::processEvents();
|
||||
return licenseKey;
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
if (licenseKey.isEmpty()) {
|
||||
while (true)
|
||||
{
|
||||
if (licenseKey.isEmpty())
|
||||
{
|
||||
licenseKey = promptAndSaveLicense(QString());
|
||||
if (licenseKey.isEmpty()) return 0;
|
||||
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)) {
|
||||
licenseKey))
|
||||
{
|
||||
break;
|
||||
}
|
||||
const QString error = identity.errorString();
|
||||
if (!isLicenseError(error)) {
|
||||
if (!isLicenseError(error))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "设备身份验证失败", error);
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Device Authentication Failed"), error);
|
||||
return -1;
|
||||
}
|
||||
clearDeviceCredential();
|
||||
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error));
|
||||
if (licenseKey.isEmpty()) return 0;
|
||||
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current license cannot be used: %1").arg(error));
|
||||
if (licenseKey.isEmpty())
|
||||
return 0;
|
||||
}
|
||||
|
||||
UpdateLogic logic;
|
||||
@@ -121,27 +159,37 @@ int main(int argc, char* argv[])
|
||||
const QString appId = logic.getAppId();
|
||||
const QString channel = logic.getChannel();
|
||||
|
||||
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
|
||||
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() ? "设备或 License 授权无效。" : message;
|
||||
if (isLicenseError(displayMessage)) {
|
||||
const QString displayMessage = message.isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "The device or license authorization is invalid.")
|
||||
: message;
|
||||
if (isLicenseError(displayMessage))
|
||||
{
|
||||
clearDeviceCredential();
|
||||
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage));
|
||||
if (!newLicense.isEmpty()) {
|
||||
const QString newLicense = promptAndSaveLicense(
|
||||
QCoreApplication::translate("Launcher", "The server rejected the current license: %1").arg(displayMessage));
|
||||
if (!newLicense.isEmpty())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。");
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "License Saved"),
|
||||
QCoreApplication::translate("Launcher", "Restart Launcher to complete device authorization and check for updates."));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage);
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Authorization Denied"), displayMessage);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const QString launchToken = config.getValue("App", "launch_token");
|
||||
const QString currentVersion = config.getValue("App", "current_version");
|
||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||
const auto configuredName = [&](const QString &key, const QString &fallback)
|
||||
{
|
||||
const QString value = config.getValue("Runtime", key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
};
|
||||
@@ -149,52 +197,98 @@ int main(int argc, char* argv[])
|
||||
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
|
||||
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
|
||||
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
|
||||
const auto importOfflinePackage = [&]() {
|
||||
const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)");
|
||||
IntegrityHelper integrity(config.installRoot());
|
||||
if (!integrity.verifyInstalledVersion(appId, channel, currentVersion))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Integrity Check Failed"),
|
||||
QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Application files failed signed manifest verification:\n%1")
|
||||
.arg(integrity.errorString()));
|
||||
return -1;
|
||||
}
|
||||
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)"));
|
||||
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
|
||||
};
|
||||
const QString deviceId = config.getValue("Update", "device_id");
|
||||
if (QCoreApplication::arguments().contains("--import-offline")) {
|
||||
if (QCoreApplication::arguments().contains("--import-offline"))
|
||||
{
|
||||
progress.close();
|
||||
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。");
|
||||
if (!importOfflinePackage())
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Offline Update"),
|
||||
QCoreApplication::translate("Launcher", "No offline update package was selected."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto startMainApp = [&]() {
|
||||
const auto startMainApp = [&]()
|
||||
{
|
||||
QString ticketPath;
|
||||
QString ticketError;
|
||||
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
|
||||
launchToken, &ticketPath, &ticketError)) {
|
||||
launchToken, &ticketPath, &ticketError))
|
||||
{
|
||||
qDebug() << "Cannot create launch ticket:" << ticketError;
|
||||
return false;
|
||||
}
|
||||
const bool started = QProcess::startDetached(mainAppPath,
|
||||
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
|
||||
if (!started) QFile::remove(ticketPath);
|
||||
if (!started)
|
||||
QFile::remove(ticketPath);
|
||||
return started;
|
||||
};
|
||||
|
||||
progress.setLabelText("正在验证本地运行策略...");
|
||||
progress.setLabelText(QCoreApplication::translate("Launcher", "Validating the local run policy..."));
|
||||
QApplication::processEvents();
|
||||
|
||||
PolicyHelper policy(appDir);
|
||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString()));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
|
||||
return -1;
|
||||
}
|
||||
if (!policy.isApplicable(appId, channel, currentVersion))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
|
||||
return -1;
|
||||
}
|
||||
if (policy.isExpired())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "The local version policy has expired. Connect to the network or contact an administrator."));
|
||||
return -1;
|
||||
}
|
||||
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
|
||||
const bool currentVersionAllowed =
|
||||
policy.allowRun() && policy.isVersionAllowed(currentVersion);
|
||||
if (!currentVersionAllowed && !(networkOk && needUpdate))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "当前版本不可运行",
|
||||
policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion)
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Current Version Cannot Run"),
|
||||
policy.message().isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "Version %1 has been disabled by an administrator.").arg(currentVersion)
|
||||
: policy.message());
|
||||
return -1;
|
||||
}
|
||||
@@ -203,19 +297,28 @@ int main(int argc, char* argv[])
|
||||
if (!state.loadState())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString()));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to 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, "安全检查失败", "检测到版本策略序列回退,已阻止启动。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Security Check Failed"),
|
||||
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
if (state.isSystemTimeRewound())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Security Check Failed"),
|
||||
QCoreApplication::translate("Launcher", "A possible system clock rollback was detected, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -223,22 +326,36 @@ int main(int argc, char* argv[])
|
||||
{
|
||||
progress.close();
|
||||
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
|
||||
const QString dialogTitle = rollbackOperation ? "版本回退"
|
||||
: (policy.forceUpdate() ? "必须更新" : "发现新版本");
|
||||
const bool updateRequired = policy.forceUpdate() || !currentVersionAllowed;
|
||||
const QString dialogTitle = rollbackOperation
|
||||
? QCoreApplication::translate("Launcher", "Version Rollback")
|
||||
: (updateRequired
|
||||
? QCoreApplication::translate("Launcher", "Update Required")
|
||||
: QCoreApplication::translate("Launcher", "New Version Available"));
|
||||
const QString prompt = policy.message().isEmpty()
|
||||
? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?"
|
||||
: "发现新版本 %1,是否现在更新?").arg(latestVer)
|
||||
: policy.message() + QString("\n目标版本:%1").arg(latestVer);
|
||||
? (rollbackOperation
|
||||
? QCoreApplication::translate("Launcher", "An administrator selected version %1 as the rollback target. Downgrade now?")
|
||||
: QCoreApplication::translate("Launcher", "Version %1 is available. Update now?"))
|
||||
.arg(latestVer)
|
||||
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
|
||||
bool accepted = true;
|
||||
if (policy.forceUpdate()) {
|
||||
if (updateRequired)
|
||||
{
|
||||
QMessageBox::information(nullptr, dialogTitle, prompt);
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
|
||||
}
|
||||
if (!accepted) {
|
||||
if (!startMainApp()) {
|
||||
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
||||
if (!accepted)
|
||||
{
|
||||
if (!startMainApp())
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Startup Failed"),
|
||||
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
@@ -246,17 +363,28 @@ int main(int argc, char* argv[])
|
||||
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
|
||||
if (!QProcess::startDetached(updaterPath, updaterArgs))
|
||||
{
|
||||
QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
|
||||
QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!networkOk) {
|
||||
if (!networkOk)
|
||||
{
|
||||
progress.close();
|
||||
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?",
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
|
||||
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。");
|
||||
if (QMessageBox::question(nullptr,
|
||||
QCoreApplication::translate("Launcher", "Server Unavailable"),
|
||||
QCoreApplication::translate("Launcher", "The update server is currently unreachable. 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();
|
||||
@@ -265,16 +393,24 @@ int main(int argc, char* argv[])
|
||||
if (!networkOk && !policy.isOfflineAllowed())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Network Unavailable"),
|
||||
QCoreApplication::translate("Launcher", "The update server is unreachable and the current policy does not allow offline startup."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
|
||||
progress.setLabelText(
|
||||
networkOk ? QCoreApplication::translate("Launcher", "The current version is up to date. Starting...")
|
||||
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
|
||||
QApplication::processEvents();
|
||||
if (!startMainApp())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Startup Failed"),
|
||||
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
|
||||
return -1;
|
||||
}
|
||||
progress.close();
|
||||
|
||||
+12
-15
@@ -1,21 +1,18 @@
|
||||
project(MainApp)
|
||||
add_executable(MainApp
|
||||
set(_main_app_sources
|
||||
main.cpp
|
||||
MainWindow.h
|
||||
MainWindow.cpp
|
||||
../Common/ConfigHelper.h
|
||||
)
|
||||
target_include_directories(MainApp
|
||||
PUBLIC ${CMAKE_SOURCE_DIR}/Common
|
||||
PRIVATE ${OPENSSL_INC}
|
||||
)
|
||||
target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET MainApp POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
|
||||
add_executable(MainApp ${_main_app_sources})
|
||||
target_compile_features(MainApp PRIVATE cxx_std_17)
|
||||
target_link_libraries(MainApp
|
||||
PRIVATE
|
||||
Common
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
endif()
|
||||
update_client_link_embedded_resources(MainApp)
|
||||
update_client_deploy_openssl_runtime(MainApp)
|
||||
update_client_enable_qt_deployment(MainApp)
|
||||
|
||||
+10
-10
@@ -12,10 +12,10 @@ static QString readDllVersion(const QString& dllPath)
|
||||
{
|
||||
QFile file(dllPath);
|
||||
if (!file.exists())
|
||||
return "missing";
|
||||
return QCoreApplication::translate("MainWindow", "missing");
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return "unreadable";
|
||||
return QCoreApplication::translate("MainWindow", "unreadable");
|
||||
|
||||
QByteArray data = file.read(1024);
|
||||
file.close();
|
||||
@@ -25,12 +25,12 @@ static QString readDllVersion(const QString& dllPath)
|
||||
MainWindow::MainWindow(QWidget* parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
this->setWindowTitle("Marsco Demo MainApp");
|
||||
this->setWindowTitle(tr("Marsco Demo MainApp"));
|
||||
this->resize(600, 400);
|
||||
|
||||
QString appVersion = ConfigHelper::instance().getValue("App", "current_version");
|
||||
if (appVersion.isEmpty())
|
||||
appVersion = "unknown";
|
||||
appVersion = tr("unknown");
|
||||
|
||||
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
|
||||
|
||||
@@ -38,27 +38,27 @@ MainWindow::MainWindow(QWidget* parent)
|
||||
QString policyResult;
|
||||
if (!policy.loadPolicy("config/version_policy.dat"))
|
||||
{
|
||||
policyResult = "policy missing";
|
||||
policyResult = tr("policy missing");
|
||||
}
|
||||
else if (!policy.isValid())
|
||||
{
|
||||
policyResult = "policy invalid";
|
||||
policyResult = tr("policy invalid");
|
||||
}
|
||||
else if (!policy.isVersionAllowed(appVersion))
|
||||
{
|
||||
policyResult = "version disabled";
|
||||
policyResult = tr("version disabled");
|
||||
}
|
||||
else if (policy.isExpired())
|
||||
{
|
||||
policyResult = "policy expired";
|
||||
policyResult = tr("policy expired");
|
||||
}
|
||||
else
|
||||
{
|
||||
policyResult = "policy ok";
|
||||
policyResult = tr("policy ok");
|
||||
}
|
||||
|
||||
QVBoxLayout* layout = new QVBoxLayout(this);
|
||||
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
|
||||
QLabel* label = new QLabel(tr("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
|
||||
.arg(appVersion)
|
||||
.arg(dllVersion)
|
||||
.arg(policyResult));
|
||||
|
||||
+50
-18
@@ -1,7 +1,5 @@
|
||||
#include <Windows.h>
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QTextCodec>
|
||||
#include <QMessageBox>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
@@ -13,13 +11,14 @@
|
||||
#include "../Common/TicketHelper.h"
|
||||
#include "../Common/IntegrityHelper.h"
|
||||
#include "../Common/DeviceIdentityHelper.h"
|
||||
#include "../Common/TranslationHelper.h"
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
SetConsoleOutputCP(936);
|
||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK"));
|
||||
|
||||
QApplication a(argc, argv);
|
||||
QApplication::setApplicationName("Marsco MainApp");
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
qDebug() << Qt::endl << "entered main app" << Qt::endl;
|
||||
|
||||
@@ -44,8 +43,9 @@ int main(int argc, char* argv[])
|
||||
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
|
||||
&ticketError))
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Startup Restriction",
|
||||
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.")
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Startup Restriction"),
|
||||
QCoreApplication::translate("MainApp", "Invalid or missing one-time launch ticket: %1\nPlease use %2.")
|
||||
.arg(ticketError, launcherExecutable));
|
||||
return -1;
|
||||
}
|
||||
@@ -55,48 +55,76 @@ int main(int argc, char* argv[])
|
||||
DeviceIdentityHelper identity(appDir);
|
||||
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
|
||||
{
|
||||
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "License Error"),
|
||||
QCoreApplication::translate("MainApp", "Local license invalid: %1").arg(identity.errorString()));
|
||||
return -1;
|
||||
}
|
||||
PolicyHelper policy(appDir);
|
||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "Local policy invalid: %1").arg(policy.errorString()));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (policy.isExpired())
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "Policy expired. The application cannot start."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
const QString currentVersion = config.getValue("App", "current_version");
|
||||
if (!policy.isApplicable(config.getValue("App", "app_id"),
|
||||
config.getValue("App", "channel"),
|
||||
currentVersion)
|
||||
|| !policy.allowRun() || !policy.isVersionAllowed(currentVersion))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate(
|
||||
"MainApp",
|
||||
"The signed policy does not allow this installed version to run."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
LocalStateHelper state(appDir);
|
||||
if (!state.loadState())
|
||||
{
|
||||
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "State Error"),
|
||||
QCoreApplication::translate("MainApp", "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.");
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "A policy sequence rollback was detected, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (state.isSystemTimeRewound())
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "The system clock appears to have moved backward, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
IntegrityHelper integrity(installRoot);
|
||||
if (!integrity.verifyInstalledVersion(
|
||||
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
||||
config.getValue("App", "current_version")))
|
||||
currentVersion))
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Integrity Check Failed",
|
||||
QString("Application files failed signed Manifest verification:\n%1")
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Integrity Check Failed"),
|
||||
QCoreApplication::translate("MainApp", "Application files failed signed manifest verification:\n%1")
|
||||
.arg(integrity.errorString()));
|
||||
return -1;
|
||||
}
|
||||
@@ -107,7 +135,9 @@ int main(int argc, char* argv[])
|
||||
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()));
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "State Error"),
|
||||
QCoreApplication::translate("MainApp", "Cannot save local state: %1").arg(state.errorString()));
|
||||
return -1;
|
||||
}
|
||||
|
||||
@@ -120,7 +150,9 @@ int main(int argc, char* argv[])
|
||||
|| healthFile.write(healthy) != healthy.size()
|
||||
|| !healthFile.commit())
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file.");
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Startup Error"),
|
||||
QCoreApplication::translate("MainApp", "Cannot write the update health confirmation file."));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# SimCAE 更新客户端
|
||||
|
||||
`update-client` 提供了 SimCAE 使用的受控启动和更新运行时。它是作为父 SimCAE 项目的一部分构建的,并不是一个独立的最终用户交付产品。
|
||||
|
||||
## 发布不变量
|
||||
|
||||
* 每个受支持的发布版本构建都通过 `Launcher` 启动;直接启动应用程序必须被拒绝。
|
||||
* 调试构建可能允许本地开发的直接启动,但它们绝不能打包给用户使用。
|
||||
* 产品配置在配置时选择并嵌入到二进制文件中。安装的 `app_config.json` 不是支持的配置机制。
|
||||
* 可变的机器状态与产品策略分开保存,并不是授权的来源。
|
||||
* 由服务器签名的策略、清单、设备凭证以及更新负载验证始终保持权威性。
|
||||
* 用户可见的源文本是英文。其他语言仅通过本地化资源提供。
|
||||
* 父级 `package_installer` 目标是唯一支持的最终用户包路径。 `package_installer_qt` 已归档且不被支持。
|
||||
|
||||
## 运行时组件
|
||||
|
||||
| 组件 | 责任 |
|
||||
| --- | --- |
|
||||
| 启动器 | 验证启动策略、设备状态和已安装文件;检查更新;创建一次性启动凭证。 |
|
||||
| 更新器 | 下载并验证更改的文件,管理更新事务,需要时请求引导替换,并提交或回滚。 |
|
||||
| Bootstrap | 替换在更新过程中无法更新的文件,然后继续事务。 |
|
||||
| 主应用 | 集成示例用于启动票的消费和更新健康报告。SimCAE 在其可执行文件中实现了相同的契约。 |
|
||||
| 公共 | 共享产品配置、状态、HTTP、身份、策略、完整性以及票务支持。 |
|
||||
|
||||
## 支持的流程
|
||||
|
||||
从父级 SimCAE 仓库中进行配置、构建、测试和打包。正常的发布流程是:
|
||||
|
||||
```powershell
|
||||
git submodule update --init --recursive
|
||||
cmake --preset SimCAE-release
|
||||
cmake --build --preset SimCAE-release --target SimCAE
|
||||
ctest --test-dir out/build/SimCAE-release -C Release --output-on-failure
|
||||
cmake --build --preset SimCAE-release --target package_installer
|
||||
```
|
||||
|
||||
不要使用已移除的 PowerShell SDK/客户端打包脚本。它们绕过了父安装规则,且不再代表交付的产品。
|
||||
|
||||
请参见 [构建和打包](docs/build-and-packaging.md) 了解前提条件、独立开发者构建、包检查和输出所有权信息。
|
||||
|
||||
## 配置
|
||||
|
||||
配置时的源文件选择顺序如下:
|
||||
|
||||
1. `config/config.json`,如果存在的话。
|
||||
2. `config/app_config.example.json`,否则。
|
||||
|
||||
所选数据将被验证并嵌入。源文件不会被复制到安装目录中。安装目录中名为 `app_config.json` 或 `client.ini` 的文件仅为遗留输入,不得覆盖嵌入的策略。
|
||||
|
||||
查看 [配置和安全](docs/configuration-and-security.md) 和 [旧配置迁移](docs/legacy-configuration-migration.md) 。
|
||||
|
||||
## 文档说明
|
||||
|
||||
* [架构](docs/architecture.md)
|
||||
* [构建和打包](docs/build-and-packaging.md)
|
||||
* [配置和安全](docs/configuration-and-security.md)
|
||||
* [本地化维护](docs/i18n.md)
|
||||
* [旧配置迁移](docs/legacy-configuration-migration.md)
|
||||
|
||||
## 仓库规则
|
||||
|
||||
* 保持生产环境的 C、C++和 CMake 文件中不含汉字。添加或更新本地化 UI 文本的翻译资源。
|
||||
* 通过 CMake 导入目标链接 Qt、OpenSSL 和其他依赖项。绝不要将开发机器的路径添加到仓库中。
|
||||
* 将发布打包保留在父项目中。此子项目可能会暴露目标和安装规则,但不会生成竞争的安装程序。
|
||||
* 不要将嵌入资源、本地设置或客户端令牌视为可以抵御机器管理员的机密信息。
|
||||
* 没有可用启动器/更新运行时的平台必须导致发布配置或打包失败。它不得回退到直接启动。
|
||||
|
||||
## 仓库布局
|
||||
|
||||
```text
|
||||
Bootstrap/ 更新交接可执行文件
|
||||
Common/ 共享运行时服务
|
||||
Launcher/ 必要的产品入口点
|
||||
MainApp/ 集成示例
|
||||
Updater/ 事务性更新可执行文件
|
||||
config/ 配置时模板和公共验证材料
|
||||
docs/ 保持的架构和操作文档
|
||||
```
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
客户端配置说明
|
||||
==============
|
||||
|
||||
统一从程序目录下的 config/app_config.json 读取配置。
|
||||
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
|
||||
|
||||
接入新软件时通常需要修改:
|
||||
|
||||
1. app_id、app_name、channel、current_version。
|
||||
2. api_base_url、client_token、license_key、launch_token。
|
||||
3. main_executable:团队业务主程序文件名。
|
||||
4. launcher_executable、updater_executable、bootstrap_executable。
|
||||
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
|
||||
|
||||
完整格式参考 config/app_config.example.json。
|
||||
运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。
|
||||
|
||||
Windows 发布打包:
|
||||
|
||||
1. 使用 Release 配置编译全部客户端程序。
|
||||
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
|
||||
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
|
||||
4. 在 PowerShell 执行:
|
||||
powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json
|
||||
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
|
||||
|
||||
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
# UpdateClientSDK 接入说明
|
||||
|
||||
这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。
|
||||
|
||||
SDK 核心程序:
|
||||
|
||||
- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。
|
||||
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
|
||||
- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。
|
||||
- `config/app_config.json`:接入方配置。
|
||||
- `config/manifest_public_key.pem`:验证服务端签名用的公钥。
|
||||
|
||||
## 接入方需要做什么
|
||||
|
||||
假设接入的软件叫 `YourApp.exe`。
|
||||
|
||||
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。
|
||||
2. 创建或确认渠道,例如 `stable`。
|
||||
3. 创建 License,把生成的 `license_key` 填到客户端配置。
|
||||
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`。
|
||||
5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。
|
||||
6. 把 `config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
|
||||
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
|
||||
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
|
||||
|
||||
## 安装目录写权限要求
|
||||
|
||||
当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如:
|
||||
|
||||
```text
|
||||
config/app_config.json
|
||||
config/client_identity.dat
|
||||
config/local_state.json
|
||||
config/version_policy.dat
|
||||
update/
|
||||
update_temp/
|
||||
```
|
||||
|
||||
所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`。
|
||||
|
||||
推荐联调目录:
|
||||
|
||||
```text
|
||||
D:\SimCAE_Release\
|
||||
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
|
||||
```
|
||||
|
||||
如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。
|
||||
|
||||
## SimCAE 目录结构建议
|
||||
|
||||
SimCAE 当前安装目录是根目录下有 `bin/`、`Licenses/`、`installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录:
|
||||
|
||||
```text
|
||||
SimCAE/
|
||||
bin/
|
||||
Launcher.exe
|
||||
Updater.exe
|
||||
Bootstrap.exe
|
||||
SimCAE.exe
|
||||
config/
|
||||
app_config.json
|
||||
manifest_public_key.pem
|
||||
update/
|
||||
manifest_cache/
|
||||
Qt5Core.dll
|
||||
...
|
||||
Licenses/
|
||||
installerResources/
|
||||
maintenancetool.exe
|
||||
```
|
||||
|
||||
这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root` 填 `..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`。
|
||||
|
||||
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll`、`platforms/`、`imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。
|
||||
|
||||
## app_config.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": "",
|
||||
"api_base_url": "http://YOUR_SERVER_IP:8000",
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
字段说明:
|
||||
|
||||
- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。
|
||||
- `channel`:发布渠道,例如 `stable`、`beta`、`dev`。
|
||||
- `current_version`:当前客户端初始版本。
|
||||
- `client_protocol`:客户端协议号,当前建议为 `3`。
|
||||
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。
|
||||
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。
|
||||
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
|
||||
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
|
||||
- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。
|
||||
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`。
|
||||
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
|
||||
|
||||
## 业务主程序需要配合什么
|
||||
|
||||
当前安全模式下,业务主程序需要配合两件事:
|
||||
|
||||
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
|
||||
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
|
||||
|
||||
当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了:
|
||||
|
||||
- 启动票据校验。
|
||||
- 本地 License/设备身份校验。
|
||||
- 本地策略校验。
|
||||
- Manifest 完整性校验。
|
||||
- 健康标记写入。
|
||||
|
||||
如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。
|
||||
|
||||
## 如何生成 SDK 包
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
.\package-sdk.ps1 `
|
||||
-SourceDir .\out\bin `
|
||||
-OutputDir .\dist\UpdateClientSDK `
|
||||
-ZipFile .\dist\UpdateClientSDK.zip `
|
||||
-SdkVersion 0.1.0
|
||||
```
|
||||
|
||||
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。
|
||||
|
||||
生成结果:
|
||||
|
||||
```text
|
||||
dist/UpdateClientSDK/
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||
sdk_manifest.json
|
||||
bin/
|
||||
config/
|
||||
app_config.json
|
||||
manifest_public_key.pem
|
||||
scripts/
|
||||
```
|
||||
|
||||
把 `UpdateClientSDK.zip` 发给接入方即可。
|
||||
|
||||
## 如何生成某个产品的最终客户端包
|
||||
|
||||
SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
.\package-client.ps1 `
|
||||
-SourceDir .\out\bin `
|
||||
-ConfigFile .\config\app_config.json `
|
||||
-OutputDir .\dist\UpdateClient `
|
||||
-ZipFile .\dist\UpdateClient.zip
|
||||
```
|
||||
|
||||
`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。
|
||||
|
||||
## 常见错误
|
||||
|
||||
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
|
||||
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
|
||||
3. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。
|
||||
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
|
||||
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
|
||||
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
|
||||
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。
|
||||
+15
-31
@@ -1,41 +1,25 @@
|
||||
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
||||
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
||||
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
||||
# 关闭VS自动Win32设计时预生成
|
||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
||||
# 清除32位Strawberry Perl路径干扰
|
||||
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include")
|
||||
list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib")
|
||||
|
||||
project(Updater)
|
||||
set(SRC
|
||||
set(_updater_sources
|
||||
main.cpp
|
||||
UpdaterLogic.h
|
||||
UpdaterLogic.cpp
|
||||
UpdateTransaction.h
|
||||
UpdateTransaction.cpp
|
||||
)
|
||||
add_executable(Updater ${SRC})
|
||||
|
||||
add_executable(Updater ${_updater_sources})
|
||||
target_compile_features(Updater PRIVATE cxx_std_17)
|
||||
target_link_libraries(Updater
|
||||
PRIVATE
|
||||
Common
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
update_client_link_embedded_resources(Updater)
|
||||
update_client_deploy_openssl_runtime(Updater)
|
||||
update_client_enable_qt_deployment(Updater)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
|
||||
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
|
||||
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
|
||||
|
||||
# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递
|
||||
target_link_libraries(Updater PRIVATE
|
||||
Common
|
||||
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
||||
)
|
||||
|
||||
# Qt部署脚本
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET Updater POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
|
||||
)
|
||||
endif()
|
||||
|
||||
+311
-61
@@ -14,7 +14,10 @@
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QUrl>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
@@ -24,6 +27,41 @@
|
||||
#include <openssl/err.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
bool jsonNonNegativeInteger(const QJsonValue& value, qint64* result)
|
||||
{
|
||||
constexpr double maxExactJsonInteger = 9007199254740991.0;
|
||||
if (!value.isDouble())
|
||||
return false;
|
||||
|
||||
const double number = value.toDouble();
|
||||
if (!std::isfinite(number) || number < 0.0 || number > maxExactJsonInteger
|
||||
|| std::floor(number) != number)
|
||||
return false;
|
||||
|
||||
if (result)
|
||||
*result = static_cast<qint64>(number);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isSha256(const QString& value)
|
||||
{
|
||||
if (value.size() != 64)
|
||||
return false;
|
||||
|
||||
for (const QChar ch : value)
|
||||
{
|
||||
const ushort code = ch.unicode();
|
||||
if (!((code >= '0' && code <= '9')
|
||||
|| (code >= 'a' && code <= 'f')
|
||||
|| (code >= 'A' && code <= 'F')))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
@@ -32,6 +70,11 @@ UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_fileItems.clear();
|
||||
m_manifestSignatureVerified = false;
|
||||
|
||||
QString url = m_serverAddr + "/api/v1/update/manifest";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
@@ -39,35 +82,64 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
|
||||
body["version"] = targetVer;
|
||||
body["version_id"] = versionId;
|
||||
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "Manifest API returned code:" << code;
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_fileItems.clear();
|
||||
m_manifestSignatureVerified = false;
|
||||
|
||||
if (code == 200)
|
||||
{
|
||||
if (resp.contains("manifest_text") && resp.contains("manifest"))
|
||||
const QJsonValue manifestTextValue = resp.value("manifest_text");
|
||||
const QJsonValue responseManifestValue = resp.value("manifest");
|
||||
if (!manifestTextValue.isString() || !responseManifestValue.isObject())
|
||||
{
|
||||
m_manifestText = resp["manifest_text"].toString();
|
||||
m_manifest = resp["manifest"].toObject();
|
||||
qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
|
||||
m_fileItems.clear();
|
||||
QJsonArray files = m_manifest.value("files").toArray();
|
||||
for (const QJsonValue& fileItem : files)
|
||||
{
|
||||
QJsonObject fileObj = fileItem.toObject();
|
||||
FileDownloadItem fi;
|
||||
fi.path = fileObj.value("path").toString();
|
||||
fi.sha256 = fileObj.value("sha256").toString();
|
||||
fi.size = fileObj.value("size").toVariant().toLongLong();
|
||||
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL
|
||||
m_fileItems.append(fi);
|
||||
}
|
||||
qDebug() << "Manifest response fields have invalid types";
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Manifest response missing fields";
|
||||
const QString manifestText = manifestTextValue.toString();
|
||||
const QJsonObject responseManifest = responseManifestValue.toObject();
|
||||
const QJsonValue signatureValue = responseManifest.value("signature");
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument manifestDocument =
|
||||
QJsonDocument::fromJson(manifestText.toUtf8(), &parseError);
|
||||
|
||||
if (manifestText.isEmpty() || !signatureValue.isString()
|
||||
|| signatureValue.toString().trimmed().isEmpty()
|
||||
|| parseError.error != QJsonParseError::NoError
|
||||
|| !manifestDocument.isObject())
|
||||
{
|
||||
qDebug() << "Manifest response JSON or signature field is invalid";
|
||||
}
|
||||
else
|
||||
{
|
||||
QJsonObject trustedManifest = manifestDocument.object();
|
||||
QList<FileDownloadItem> trustedFiles;
|
||||
if (trustedManifest.contains("signature"))
|
||||
{
|
||||
qDebug() << "Signed manifest text unexpectedly contains a signature field";
|
||||
}
|
||||
else
|
||||
{
|
||||
trustedManifest.insert("signature", signatureValue.toString());
|
||||
if (validateOnlineManifest(trustedManifest, appId, channel,
|
||||
targetVer, versionId, &trustedFiles))
|
||||
{
|
||||
m_manifestText = manifestText;
|
||||
m_manifest = trustedManifest;
|
||||
m_fileItems = trustedFiles;
|
||||
qDebug() << "Received trusted manifest version:"
|
||||
<< m_manifest.value("version").toString();
|
||||
}
|
||||
else
|
||||
{
|
||||
qDebug() << "Manifest fields or request identity are invalid";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -78,6 +150,76 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
|
||||
});
|
||||
}
|
||||
|
||||
bool UpdaterLogic::validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
|
||||
const QString& channel, const QString& targetVer,
|
||||
int versionId, QList<FileDownloadItem>* fileItems) const
|
||||
{
|
||||
if (!fileItems)
|
||||
return false;
|
||||
fileItems->clear();
|
||||
|
||||
const QJsonValue appValue = manifest.value("app_id");
|
||||
const QJsonValue channelValue = manifest.value("channel");
|
||||
const QJsonValue versionValue = manifest.value("version");
|
||||
const QJsonValue sequenceValue = manifest.value("manifest_seq");
|
||||
const QJsonValue filesValue = manifest.value("files");
|
||||
const QJsonValue signatureValue = manifest.value("signature");
|
||||
qint64 manifestSequence = -1;
|
||||
if (!appValue.isString() || appValue.toString().isEmpty()
|
||||
|| !channelValue.isString() || channelValue.toString().isEmpty()
|
||||
|| !versionValue.isString() || versionValue.toString().isEmpty()
|
||||
|| !jsonNonNegativeInteger(sequenceValue, &manifestSequence)
|
||||
|| !filesValue.isArray()
|
||||
|| !signatureValue.isString() || signatureValue.toString().trimmed().isEmpty()
|
||||
|| appValue.toString() != appId
|
||||
|| channelValue.toString() != channel
|
||||
|| versionValue.toString() != targetVer
|
||||
|| manifestSequence != static_cast<qint64>(versionId))
|
||||
return false;
|
||||
|
||||
QSet<QString> pathKeys;
|
||||
const QJsonArray files = filesValue.toArray();
|
||||
fileItems->reserve(files.size());
|
||||
for (const QJsonValue& value : files)
|
||||
{
|
||||
if (!value.isObject())
|
||||
{
|
||||
fileItems->clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject file = value.toObject();
|
||||
const QJsonValue pathValue = file.value("path");
|
||||
const QJsonValue shaValue = file.value("sha256");
|
||||
const QJsonValue sizeValue = file.value("size");
|
||||
qint64 size = -1;
|
||||
if (!pathValue.isString() || pathValue.toString().isEmpty()
|
||||
|| !shaValue.isString() || !isSha256(shaValue.toString())
|
||||
|| !jsonNonNegativeInteger(sizeValue, &size))
|
||||
{
|
||||
fileItems->clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString path = pathValue.toString();
|
||||
const QString normalizedPath = QDir::cleanPath(QDir::fromNativeSeparators(path));
|
||||
const QString pathKey = normalizedPath.toCaseFolded();
|
||||
if (!isSafeRelativePath(path) || pathKeys.contains(pathKey))
|
||||
{
|
||||
fileItems->clear();
|
||||
return false;
|
||||
}
|
||||
pathKeys.insert(pathKey);
|
||||
|
||||
FileDownloadItem item;
|
||||
item.path = path;
|
||||
item.sha256 = shaValue.toString();
|
||||
item.size = size;
|
||||
fileItems->append(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
@@ -146,6 +288,7 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
||||
|
||||
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
{
|
||||
m_manifestSignatureVerified = false;
|
||||
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
||||
{
|
||||
qDebug() << "No manifest available to verify";
|
||||
@@ -158,17 +301,13 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
return false;
|
||||
}
|
||||
|
||||
QString path = publicKeyPath;
|
||||
if (!QFile::exists(path))
|
||||
{
|
||||
path = QApplication::applicationDirPath() + "/" + publicKeyPath;
|
||||
}
|
||||
return verifySignature(m_manifestText.toUtf8(), signature, path);
|
||||
m_manifestSignatureVerified = verifySignature(m_manifestText.toUtf8(), signature, publicKeyPath);
|
||||
return m_manifestSignatureVerified;
|
||||
}
|
||||
|
||||
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
{
|
||||
if (m_manifest.isEmpty())
|
||||
if (m_manifest.isEmpty() || !m_manifestSignatureVerified)
|
||||
return false;
|
||||
|
||||
QDir dir(cacheDir);
|
||||
@@ -194,6 +333,11 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
|
||||
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
|
||||
{
|
||||
m_manifest = QJsonObject();
|
||||
m_manifestText.clear();
|
||||
m_fileItems.clear();
|
||||
m_manifestSignatureVerified = false;
|
||||
|
||||
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
||||
QFile file(filePath);
|
||||
if (!file.exists() || !file.open(QIODevice::ReadOnly))
|
||||
@@ -201,16 +345,37 @@ bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& ver
|
||||
|
||||
QByteArray raw = file.readAll();
|
||||
file.close();
|
||||
QJsonDocument doc = QJsonDocument::fromJson(raw);
|
||||
if (!doc.isObject())
|
||||
QJsonParseError wrapperError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &wrapperError);
|
||||
if (wrapperError.error != QJsonParseError::NoError || !doc.isObject())
|
||||
return false;
|
||||
|
||||
QJsonObject wrapper = doc.object();
|
||||
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text"))
|
||||
const QJsonObject wrapper = doc.object();
|
||||
const QJsonValue manifestTextValue = wrapper.value("manifest_text");
|
||||
const QJsonValue cachedManifestValue = wrapper.value("manifest");
|
||||
if (!manifestTextValue.isString() || manifestTextValue.toString().isEmpty()
|
||||
|| !cachedManifestValue.isObject())
|
||||
return false;
|
||||
|
||||
m_manifest = wrapper["manifest"].toObject();
|
||||
m_manifestText = wrapper["manifest_text"].toString();
|
||||
const QJsonValue signatureValue = cachedManifestValue.toObject().value("signature");
|
||||
QJsonParseError manifestError;
|
||||
const QString manifestText = manifestTextValue.toString();
|
||||
const QJsonDocument manifestDocument =
|
||||
QJsonDocument::fromJson(manifestText.toUtf8(), &manifestError);
|
||||
if (!signatureValue.isString() || signatureValue.toString().trimmed().isEmpty()
|
||||
|| manifestError.error != QJsonParseError::NoError
|
||||
|| !manifestDocument.isObject())
|
||||
return false;
|
||||
|
||||
QJsonObject trustedManifest = manifestDocument.object();
|
||||
const QJsonValue versionValue = trustedManifest.value("version");
|
||||
if (trustedManifest.contains("signature") || !versionValue.isString()
|
||||
|| versionValue.toString() != version)
|
||||
return false;
|
||||
|
||||
trustedManifest.insert("signature", signatureValue.toString());
|
||||
m_manifest = trustedManifest;
|
||||
m_manifestText = manifestText;
|
||||
qDebug() << "Loaded cached manifest" << version;
|
||||
return true;
|
||||
}
|
||||
@@ -341,50 +506,66 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
|
||||
{
|
||||
m_offlineError.clear();
|
||||
QFile package(packagePath);
|
||||
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; }
|
||||
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; }
|
||||
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot open the offline update package."); return false; }
|
||||
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package format identifier is invalid."); return false; }
|
||||
const QByteArray lengthBytes = package.read(8);
|
||||
if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; }
|
||||
if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete."); return false; }
|
||||
quint64 headerSize = 0;
|
||||
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
|
||||
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; }
|
||||
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header length is invalid."); return false; }
|
||||
QJsonParseError error;
|
||||
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
|
||||
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; }
|
||||
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header contains invalid JSON."); return false; }
|
||||
const QJsonObject wrapper = wrapperDoc.object();
|
||||
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
|
||||
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem";
|
||||
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; }
|
||||
const QString publicKey = QStringLiteral(":/simcae/update-client/manifest-public-key.pem");
|
||||
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package RSA signature is invalid."); return false; }
|
||||
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
|
||||
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; }
|
||||
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; }
|
||||
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The signed offline package metadata is invalid."); return false; }
|
||||
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The manifest digest does not match the package signature."); return false; }
|
||||
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
|
||||
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; 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());
|
||||
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 = "包信息与 Manifest 身份不一致"; return false; }
|
||||
if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; }
|
||||
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", "The package metadata and manifest identity do not match."); return false; }
|
||||
if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid."); return false; }
|
||||
const qint64 payloadStart = 16 + qint64(headerSize);
|
||||
const QJsonArray entries = packageMeta.value("files").toArray();
|
||||
for (const QJsonValue& value : entries) {
|
||||
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
|
||||
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + 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-bounds data: %1").arg(path); return false; }
|
||||
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
|
||||
if (stagingDir.isEmpty()) continue;
|
||||
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; }
|
||||
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + 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 the offline file: %1").arg(path); return false; }
|
||||
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create the staged file: %1").arg(path); return false; }
|
||||
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
|
||||
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); }
|
||||
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; }
|
||||
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Failed to read the offline file: %1").arg(path); return false; } hash.addData(block); remaining -= block.size(); }
|
||||
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Offline file hash validation or writing failed: %1").arg(path); return false; }
|
||||
}
|
||||
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; }
|
||||
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The number of files in the offline package does not match the manifest."); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
QList<FileDownloadItem> signedFiles;
|
||||
m_fileItems.clear();
|
||||
if (!m_manifestSignatureVerified
|
||||
|| !validateOnlineManifest(m_manifest, appId, channel, targetVer, versionId, &signedFiles))
|
||||
{
|
||||
qDebug() << "Download URL request rejected because the signed manifest is not valid for the request";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
if (signedFiles.isEmpty())
|
||||
{
|
||||
qDebug() << "Download URL request rejected because the signed manifest has no files";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
@@ -395,25 +576,94 @@ void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel,
|
||||
QJsonArray emptyFiles;
|
||||
body["files"] = emptyFiles;
|
||||
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
m_http.postRequest(url, body, [this, signedFiles](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)
|
||||
const QJsonValue filesValue = resp.value("files");
|
||||
if (!filesValue.isArray())
|
||||
{
|
||||
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;
|
||||
qDebug() << "Download URL response files field is invalid";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonArray responseFiles = filesValue.toArray();
|
||||
if (responseFiles.size() != signedFiles.size())
|
||||
{
|
||||
qDebug() << "Download URL response file count does not match the signed manifest";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
QMap<QString, int> signedIndexes;
|
||||
for (int index = 0; index < signedFiles.size(); ++index)
|
||||
signedIndexes.insert(signedFiles.at(index).path, index);
|
||||
|
||||
QList<FileDownloadItem> boundFiles = signedFiles;
|
||||
QSet<QString> responsePaths;
|
||||
for (const QJsonValue& value : responseFiles)
|
||||
{
|
||||
if (!value.isObject())
|
||||
{
|
||||
qDebug() << "Download URL response contains a non-object file item";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject responseFile = value.toObject();
|
||||
const QJsonValue pathValue = responseFile.value("path");
|
||||
const QJsonValue urlValue = responseFile.value("url");
|
||||
const QJsonValue shaValue = responseFile.value("sha256");
|
||||
const QUrl downloadUrl(urlValue.toString());
|
||||
qint64 size = -1;
|
||||
if (!pathValue.isString() || pathValue.toString().isEmpty()
|
||||
|| !urlValue.isString() || urlValue.toString().trimmed().isEmpty()
|
||||
|| !downloadUrl.isValid() || downloadUrl.host().isEmpty()
|
||||
|| (downloadUrl.scheme().compare("http", Qt::CaseInsensitive) != 0
|
||||
&& downloadUrl.scheme().compare("https", Qt::CaseInsensitive) != 0)
|
||||
|| !shaValue.isString() || !isSha256(shaValue.toString())
|
||||
|| !jsonNonNegativeInteger(responseFile.value("size"), &size))
|
||||
{
|
||||
qDebug() << "Download URL response contains invalid file metadata";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QString path = pathValue.toString();
|
||||
if (responsePaths.contains(path) || !signedIndexes.contains(path))
|
||||
{
|
||||
qDebug() << "Download URL response contains a duplicate or extra file:" << path;
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
responsePaths.insert(path);
|
||||
|
||||
const int index = signedIndexes.value(path);
|
||||
const FileDownloadItem& signedFile = signedFiles.at(index);
|
||||
if (shaValue.toString().compare(signedFile.sha256, Qt::CaseInsensitive) != 0
|
||||
|| size != signedFile.size)
|
||||
{
|
||||
qDebug() << "Download URL response metadata differs from the signed manifest:" << path;
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
boundFiles[index].url = urlValue.toString();
|
||||
}
|
||||
|
||||
if (responsePaths.size() != signedFiles.size())
|
||||
{
|
||||
qDebug() << "Download URL response is missing signed manifest files";
|
||||
emit fetchUrlFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
m_fileItems = boundFiles;
|
||||
qDebug() << "Bound download URLs to" << m_fileItems.size()
|
||||
<< "signed manifest files";
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -514,7 +764,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
||||
|
||||
if (existingSize > 0 && httpStatus == 200)
|
||||
{
|
||||
// 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。
|
||||
// The server ignored Range, so restart instead of appending a full response to the partial file.
|
||||
qDebug() << "Server ignored Range; restart full download:" << savePath;
|
||||
QFile::remove(partPath);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public:
|
||||
explicit UpdaterLogic(QObject* parent = nullptr);
|
||||
|
||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
||||
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
|
||||
bool verifyManifestSignature(const QString& publicKeyPath = ":/simcae/update-client/manifest-public-key.pem") const;
|
||||
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
||||
bool saveManifestCache(const QString& cacheDir) const;
|
||||
bool loadManifestCache(const QString& cacheDir, const QString& version);
|
||||
@@ -53,6 +53,9 @@ signals:
|
||||
private:
|
||||
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
|
||||
qint64 expectedSize, const QString& resumePartPath);
|
||||
bool validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
|
||||
const QString& channel, const QString& targetVer,
|
||||
int versionId, QList<FileDownloadItem>* fileItems) const;
|
||||
bool isSafeRelativePath(const QString& path) const;
|
||||
bool isRuntimeProtectedPath(const QString& path) const;
|
||||
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
|
||||
@@ -63,6 +66,7 @@ private:
|
||||
QJsonObject m_manifest;
|
||||
QString m_manifestText;
|
||||
QList<FileDownloadItem> m_fileItems;
|
||||
mutable bool m_manifestSignatureVerified = false;
|
||||
bool m_downloadAllOk = false;
|
||||
qint64 m_downloadTotalBytes = 0;
|
||||
qint64 m_downloadCompletedBytes = 0;
|
||||
|
||||
+188
-73
@@ -1,4 +1,3 @@
|
||||
#include <windows.h>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
@@ -11,7 +10,6 @@
|
||||
#include <QProgressDialog>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QTextCodec>
|
||||
#include <QThread>
|
||||
#include "UpdaterLogic.h"
|
||||
#include "UpdateTransaction.h"
|
||||
@@ -19,15 +17,17 @@
|
||||
#include "ConfigHelper.h"
|
||||
#include "TicketHelper.h"
|
||||
#include "PolicyHelper.h"
|
||||
#include "TranslationHelper.h"
|
||||
#include <QVersionNumber>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
SetConsoleOutputCP(65001);
|
||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("Marsco Updater");
|
||||
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
UpdaterLogic logic;
|
||||
QString offlinePackagePath;
|
||||
QString appId;
|
||||
@@ -37,7 +37,7 @@ int main(int argc, char* argv[])
|
||||
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
|
||||
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath)) {
|
||||
QMessageBox::critical(nullptr, "离线包无效", logic.offlineError());
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Updater", "Invalid Offline Package"), logic.offlineError());
|
||||
return -1;
|
||||
}
|
||||
const QJsonObject packageManifest = logic.getManifest();
|
||||
@@ -47,7 +47,10 @@ int main(int argc, char* argv[])
|
||||
targetVersionId = packageManifest.value("manifest_seq").toInt();
|
||||
} else {
|
||||
if (argc < 5) {
|
||||
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Invalid Updater Arguments"),
|
||||
QCoreApplication::translate("Updater", "The updater requires online update arguments or an offline update package."));
|
||||
return -1;
|
||||
}
|
||||
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
|
||||
@@ -66,24 +69,40 @@ int main(int argc, char* argv[])
|
||||
const QString updateDir = config.updateRoot();
|
||||
QDir().mkpath(updateDir);
|
||||
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
||||
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
|
||||
QCoreApplication::translate("Updater", "The update package application or channel does not match this installation."));
|
||||
return -1;
|
||||
}
|
||||
QString fromVersion = config.getValue("App", "current_version");
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
PolicyHelper offlinePolicy(runtimeDir);
|
||||
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
||||
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
||||
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
|
||||
PolicyHelper updatePolicy(runtimeDir);
|
||||
const bool policyApplicable =
|
||||
updatePolicy.loadPolicy() && updatePolicy.isValid()
|
||||
&& updatePolicy.isApplicable(appId, channel, fromVersion)
|
||||
&& !updatePolicy.isExpired()
|
||||
&& updatePolicy.isVersionAllowed(targetVersion);
|
||||
if (!policyApplicable
|
||||
|| (!offlinePackagePath.isEmpty() && !updatePolicy.isOfflineAllowed()))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Denied"),
|
||||
QCoreApplication::translate(
|
||||
"Updater",
|
||||
"The local signed policy is invalid, expired, not applicable to this installation, or does not allow the requested update."));
|
||||
return -1;
|
||||
}
|
||||
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
||||
QVersionNumber::fromString(fromVersion)) < 0
|
||||
&& !offlinePolicy.allowRollback()) {
|
||||
QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。");
|
||||
&& !updatePolicy.allowRollback())
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Downgrade Prohibited"),
|
||||
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an older version."));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
QString deviceId = config.getValue("Update", "device_id");
|
||||
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
||||
|
||||
@@ -91,21 +110,27 @@ int main(int argc, char* argv[])
|
||||
QString restoredVersion;
|
||||
QString recoveryError;
|
||||
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败",
|
||||
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Recovery Failed"),
|
||||
QCoreApplication::translate("Updater", "The previous update did not finish, and the old version could not be restored: %1\nDo not continue running the software. Contact an administrator.")
|
||||
.arg(recoveryError));
|
||||
return -1;
|
||||
}
|
||||
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
|
||||
if (!config.setValue("App", "current_version", restoredVersion)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Recovery Failed"),
|
||||
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Check access to the system state store."));
|
||||
return -1;
|
||||
}
|
||||
fromVersion = restoredVersion;
|
||||
}
|
||||
}
|
||||
|
||||
QProgressDialog progress("正在准备更新...", QString(), 0, 100);
|
||||
progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion));
|
||||
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing the update..."), QString(), 0, 100);
|
||||
progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
|
||||
progress.setCancelButton(nullptr);
|
||||
progress.setWindowModality(Qt::ApplicationModal);
|
||||
progress.setMinimumDuration(0);
|
||||
@@ -133,10 +158,11 @@ int main(int argc, char* argv[])
|
||||
QObject::connect(&logic, &UpdaterLogic::downloadProgress,
|
||||
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
|
||||
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
|
||||
const QString fileText = path.isEmpty() ? QString("正在准备下载...")
|
||||
: QString("正在下载:%1").arg(path);
|
||||
const QString fileText = path.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Preparing the download...")
|
||||
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
|
||||
progress.setValue(value);
|
||||
progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
|
||||
progress.setLabelText(QString("%1\n%2 / %3 | %4/s")
|
||||
.arg(fileText, formatBytes(received), formatBytes(total),
|
||||
formatBytes(static_cast<qint64>(bytesPerSecond))));
|
||||
QApplication::processEvents();
|
||||
@@ -196,7 +222,7 @@ int main(int argc, char* argv[])
|
||||
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
||||
FileHelper::killProcess(mainExecutable);
|
||||
transaction.markRollbackRequired(reason);
|
||||
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
||||
progress.setLabelText(QCoreApplication::translate("Updater", "Handing rollback over to Bootstrap..."));
|
||||
QApplication::processEvents();
|
||||
if (launchBootstrap("rollback")) {
|
||||
progress.close();
|
||||
@@ -205,7 +231,7 @@ int main(int argc, char* argv[])
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, title,
|
||||
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
||||
reason + QCoreApplication::translate("Updater", "\n\nBootstrap could not be started to perform the rollback. Do not continue running the software. Contact an administrator."));
|
||||
return -1;
|
||||
};
|
||||
|
||||
@@ -214,8 +240,10 @@ int main(int argc, char* argv[])
|
||||
if (!transaction.resumeExisting(&resumeError)) {
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "事务续办失败",
|
||||
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Transaction Resume Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
|
||||
return -1;
|
||||
}
|
||||
fromVersion = transaction.fromVersion();
|
||||
@@ -227,38 +255,60 @@ int main(int argc, char* argv[])
|
||||
reportUpdateResult(false);
|
||||
if (stateOk) launchMainApp();
|
||||
progress.close();
|
||||
QMessageBox::warning(nullptr, "更新已回滚",
|
||||
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。"
|
||||
: "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。");
|
||||
QMessageBox::warning(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Rolled Back"),
|
||||
stateOk
|
||||
? QCoreApplication::translate("Updater", "The new version could not be installed or started. The old version was restored and started automatically.")
|
||||
: QCoreApplication::translate("Updater", "The old files were restored, but the old version number could not be written back. Check permissions on the system state store."));
|
||||
return stateOk ? 0 : -1;
|
||||
}
|
||||
if (bootstrapResult != "success") {
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "自动回滚失败",
|
||||
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Automatic Rollback Failed"),
|
||||
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator."));
|
||||
return -1;
|
||||
}
|
||||
} else if (!transaction.initialize()) {
|
||||
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save its state."),
|
||||
"transaction_init_failed");
|
||||
} else if (!offlinePackagePath.isEmpty()) {
|
||||
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
|
||||
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
|
||||
return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
|
||||
"offline_marker_failed");
|
||||
}
|
||||
|
||||
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
|
||||
setProgress(
|
||||
resumingFromBootstrap ? 72 : 10,
|
||||
offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Fetching and validating the version manifest...")
|
||||
: QCoreApplication::translate("Updater", "Validating the offline update package..."));
|
||||
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
|
||||
if (resumingFromBootstrap) {
|
||||
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
||||
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "The signed manifest cache could not be read after Bootstrap installation."));
|
||||
} else if (offlinePackagePath.isEmpty()) {
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
||||
}
|
||||
if (!logic.verifyManifestSignature()) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
||||
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Security Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The version manifest signature could not be revalidated after Bootstrap installation."));
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Security Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Contact an administrator."),
|
||||
"manifest_signature_invalid");
|
||||
}
|
||||
QStringList obsoletePaths;
|
||||
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
||||
@@ -274,45 +324,68 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
if (!logic.saveManifestCache(manifestCacheDir)) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。");
|
||||
return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
|
||||
"manifest_cache_failed");
|
||||
}
|
||||
|
||||
if (!resumingFromBootstrap) {
|
||||
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件...");
|
||||
setProgress(
|
||||
25,
|
||||
offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
|
||||
: QCoreApplication::translate("Updater", "Preparing offline package files..."));
|
||||
if (offlinePackagePath.isEmpty())
|
||||
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||
if (logic.getFileList().isEmpty())
|
||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "No Update Files"),
|
||||
QCoreApplication::translate("Updater", "The server returned no version files. The update has stopped."),
|
||||
"empty_file_list");
|
||||
|
||||
QStorageInfo storage(updateDir);
|
||||
storage.refresh();
|
||||
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
||||
const qint64 availableBytes = storage.bytesAvailable();
|
||||
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
|
||||
return fail("磁盘空间不足",
|
||||
QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n"
|
||||
"所需空间已包含下载文件、旧版本备份和安全余量。")
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Insufficient Disk Space"),
|
||||
QCoreApplication::translate("Updater", "The update requires at least %1 of free space, but the installation drive has only %2 available.\nThe required space includes downloaded files, the old-version backup, and a safety margin.")
|
||||
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
|
||||
"disk_space_insufficient");
|
||||
}
|
||||
|
||||
const QString stagingDir = transaction.stagingDir();
|
||||
setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size()));
|
||||
setProgress(
|
||||
30,
|
||||
(offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Downloading and validating %1 version files...")
|
||||
: QCoreApplication::translate("Updater", "Extracting and validating %1 offline files..."))
|
||||
.arg(logic.getFileList().size()));
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
|
||||
return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid");
|
||||
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"), logic.offlineError(), "offline_package_invalid");
|
||||
} else {
|
||||
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
|
||||
logic.reportDownloadResult(appId, channel, targetVersion, false);
|
||||
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Download Failed"),
|
||||
QCoreApplication::translate("Updater", "Some files could not be downloaded or failed SHA-256 validation. Check the network and try again."),
|
||||
"download_failed");
|
||||
}
|
||||
logic.reportDownloadResult(appId, channel, targetVersion, true);
|
||||
}
|
||||
|
||||
setProgress(58, "正在校验完整版本文件...");
|
||||
setProgress(58, QCoreApplication::translate("Updater", "Validating the complete version files..."));
|
||||
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
||||
return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "File Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
|
||||
"staging_verify_failed");
|
||||
|
||||
QStringList changedPaths;
|
||||
QDir stagingRoot(stagingDir);
|
||||
@@ -324,24 +397,39 @@ int main(int argc, char* argv[])
|
||||
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
|
||||
for (const QString& path : changedPaths) {
|
||||
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
||||
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Bootstrap Cannot Update Itself"),
|
||||
QCoreApplication::translate("Updater", "This version contains a new %1. Upgrade this component with the installer, then republish the application version.").arg(bootstrapManifestPath),
|
||||
"bootstrap_self_update_blocked");
|
||||
}
|
||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Transaction Recording Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the validated-file or pending-deletion lists. The update has stopped."),
|
||||
"transaction_record_failed");
|
||||
|
||||
setProgress(66, "正在关闭主程序...");
|
||||
setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
|
||||
if (!FileHelper::killProcess(mainExecutable))
|
||||
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Cannot Close Main Application"),
|
||||
QCoreApplication::translate("Updater", "%1 is still running. Close it manually and try again.").arg(mainExecutable),
|
||||
"mainapp_close_failed");
|
||||
|
||||
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
|
||||
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 deletions...")
|
||||
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
||||
if (!transaction.backupCurrentFiles())
|
||||
return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "backup_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Backup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot back up the current version files. The new version has not been installed. Check disk space and directory permissions."),
|
||||
"backup_failed");
|
||||
|
||||
const QString planFile = bootstrapPlanFile();
|
||||
QSaveFile plan(planFile);
|
||||
if (!plan.open(QIODevice::WriteOnly))
|
||||
return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
|
||||
"bootstrap_plan_failed");
|
||||
const auto writePlanItem = [&](char operation, const QString& path) {
|
||||
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
|
||||
return plan.write(line) == line.size();
|
||||
@@ -349,43 +437,63 @@ int main(int argc, char* argv[])
|
||||
for (const QString& path : changedPaths) {
|
||||
if (!writePlanItem('C', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
|
||||
"bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
for (const QString& path : obsoletePaths) {
|
||||
if (!writePlanItem('D', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
|
||||
"bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
if (!plan.commit() || !transaction.markAwaitingBootstrap())
|
||||
return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
|
||||
"bootstrap_plan_failed");
|
||||
|
||||
setProgress(78, "正在将安装工作移交给 Bootstrap...");
|
||||
setProgress(78, QCoreApplication::translate("Updater", "Handing installation over to Bootstrap..."));
|
||||
if (!launchBootstrap("install"))
|
||||
return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot start the independent update handoff program: %1").arg(bootstrapPath),
|
||||
"bootstrap_start_failed");
|
||||
progress.close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
setProgress(82, "正在校验 Bootstrap 安装结果...");
|
||||
setProgress(82, QCoreApplication::translate("Updater", "Validating the Bootstrap installation result..."));
|
||||
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
|
||||
return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Installation Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version files failed validation after installation."));
|
||||
for (const QString& path : transaction.obsoletePaths()) {
|
||||
if (QFile::exists(QDir(targetDir).filePath(path)))
|
||||
return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
|
||||
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
|
||||
}
|
||||
|
||||
setProgress(89, "正在保存新版本状态...");
|
||||
setProgress(89, QCoreApplication::translate("Updater", "Saving the new version state..."));
|
||||
if (!config.setValue("App", "current_version", targetVersion)
|
||||
|| config.getValue("App", "current_version") != targetVersion)
|
||||
return delegateRollback("状态保存失败", "无法保存当前版本号。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "State Save Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the current version number."));
|
||||
|
||||
const QString healthFile = transaction.healthFile();
|
||||
QFile::remove(healthFile);
|
||||
setProgress(94, "正在启动新版本并等待健康确认...");
|
||||
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for a health confirmation..."));
|
||||
if (!launchMainApp(healthFile))
|
||||
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "%1 could not be started.").arg(mainExecutable));
|
||||
|
||||
QElapsedTimer healthTimer;
|
||||
healthTimer.start();
|
||||
@@ -394,17 +502,24 @@ int main(int argc, char* argv[])
|
||||
QThread::msleep(100);
|
||||
}
|
||||
if (!QFile::exists(healthFile))
|
||||
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version did not complete its startup health confirmation within %1 milliseconds.").arg(healthCheckTimeoutMs));
|
||||
|
||||
setProgress(99, "正在提交更新事务...");
|
||||
setProgress(99, QCoreApplication::translate("Updater", "Committing the update transaction..."));
|
||||
if (!transaction.commit())
|
||||
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Transaction Commit Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version started, but the update transaction could not be committed."));
|
||||
|
||||
QFile::remove(healthFile);
|
||||
QFile::remove(bootstrapPlanFile());
|
||||
reportUpdateResult(true);
|
||||
progress.setValue(100);
|
||||
progress.close();
|
||||
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Complete"),
|
||||
QCoreApplication::translate("Updater", "The software was updated successfully to %1 and passed the startup health check.").arg(targetVersion));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
if(NOT UPDATE_CLIENT_SCAN_ROOT)
|
||||
message(FATAL_ERROR "UPDATE_CLIENT_SCAN_ROOT is required.")
|
||||
endif()
|
||||
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_SCAN_ROOT
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _scan_root
|
||||
)
|
||||
if(NOT IS_DIRECTORY "${_scan_root}")
|
||||
message(FATAL_ERROR "Production source scan root does not exist: ${_scan_root}")
|
||||
endif()
|
||||
|
||||
file(GLOB_RECURSE _all_files LIST_DIRECTORIES FALSE "${_scan_root}/*")
|
||||
set(_production_files)
|
||||
foreach(_candidate IN LISTS _all_files)
|
||||
cmake_path(CONVERT "${_candidate}" TO_CMAKE_PATH_LIST _normalized_candidate NORMALIZE)
|
||||
cmake_path(
|
||||
RELATIVE_PATH _normalized_candidate
|
||||
BASE_DIRECTORY "${_scan_root}"
|
||||
OUTPUT_VARIABLE _relative_candidate
|
||||
)
|
||||
string(TOLOWER "${_relative_candidate}" _candidate_lower)
|
||||
if(_candidate_lower MATCHES
|
||||
"(^|/)(translations|docs|out|build|generated|cmake-build[^/]*|\\.git)(/|$)")
|
||||
continue()
|
||||
endif()
|
||||
if(_candidate_lower STREQUAL "bootstrap/bootstrap.rc")
|
||||
continue()
|
||||
endif()
|
||||
|
||||
cmake_path(GET _normalized_candidate FILENAME _file_name)
|
||||
cmake_path(GET _normalized_candidate EXTENSION _file_extension)
|
||||
string(TOLOWER "${_file_extension}" _file_extension)
|
||||
if(NOT _file_name STREQUAL "CMakeLists.txt"
|
||||
AND NOT _file_extension MATCHES
|
||||
"^\\.(c|cc|cpp|cxx|h|hh|hpp|cmake|ui|qrc|manifest|rc|json)$")
|
||||
continue()
|
||||
endif()
|
||||
list(APPEND _production_files "${_candidate}")
|
||||
endforeach()
|
||||
|
||||
set(_han_utf8_regex
|
||||
"(e3:[9ab][0-9a-f]:[89ab][0-9a-f]:"
|
||||
"|e[4-9]:[89ab][0-9a-f]:[89ab][0-9a-f]:"
|
||||
"|ef:a[4-9ab]:[89ab][0-9a-f]:"
|
||||
"|f0:a[0-9a-f]:[89ab][0-9a-f]:[89ab][0-9a-f]:"
|
||||
"|f0:b[01]:[89ab][0-9a-f]:[89ab][0-9a-f]:)"
|
||||
)
|
||||
string(JOIN "" _han_utf8_regex ${_han_utf8_regex})
|
||||
|
||||
set(_violations)
|
||||
foreach(_production_file IN LISTS _production_files)
|
||||
file(READ "${_production_file}" _file_hex HEX)
|
||||
string(TOLOWER "${_file_hex}" _file_hex)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "\\1:" _file_bytes "${_file_hex}")
|
||||
if(_file_bytes MATCHES "${_han_utf8_regex}")
|
||||
cmake_path(
|
||||
RELATIVE_PATH _production_file
|
||||
BASE_DIRECTORY "${_scan_root}"
|
||||
OUTPUT_VARIABLE _relative_file
|
||||
)
|
||||
list(APPEND _violations "${_relative_file}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_violations)
|
||||
list(JOIN _violations "\n " _violation_list)
|
||||
message(FATAL_ERROR
|
||||
"Han characters are forbidden in update-client production sources:\n"
|
||||
" ${_violation_list}\n"
|
||||
"Move user-visible text to the Qt translation catalog or the approved "
|
||||
"Bootstrap STRINGTABLE resource."
|
||||
)
|
||||
endif()
|
||||
|
||||
list(LENGTH _production_files _production_file_count)
|
||||
message(STATUS
|
||||
"No-Han production source check passed (${_production_file_count} files)."
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
include_guard(DIRECTORY)
|
||||
|
||||
function(_update_client_normalize_thirdparty_root)
|
||||
if(NOT UPDATE_CLIENT_THIRDPARTY_ROOT)
|
||||
message(FATAL_ERROR
|
||||
"UPDATE_CLIENT_THIRDPARTY_ROOT is empty. Set it to the project third-party "
|
||||
"root, normally <SimCAE source>/3rdparty."
|
||||
)
|
||||
endif()
|
||||
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_THIRDPARTY_ROOT
|
||||
BASE_DIRECTORY "${UPDATE_CLIENT_SOURCE_DIR}"
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _absolute_root
|
||||
)
|
||||
if(NOT IS_DIRECTORY "${_absolute_root}")
|
||||
message(FATAL_ERROR
|
||||
"UPDATE_CLIENT_THIRDPARTY_ROOT does not exist: ${_absolute_root}\n"
|
||||
"Provision the project dependencies or pass the correct project-relative root."
|
||||
)
|
||||
endif()
|
||||
|
||||
set(
|
||||
UPDATE_CLIENT_THIRDPARTY_ROOT
|
||||
"${_absolute_root}"
|
||||
CACHE PATH
|
||||
"Root directory for update-client third-party packages"
|
||||
FORCE
|
||||
)
|
||||
set(UPDATE_CLIENT_THIRDPARTY_ROOT "${_absolute_root}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_assert_path_under_root candidate_path description root_path)
|
||||
if(candidate_path MATCHES "^\\$<")
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation cannot verify ${description}: ${candidate_path}"
|
||||
)
|
||||
endif()
|
||||
if(NOT EXISTS "${candidate_path}")
|
||||
message(FATAL_ERROR "${description} does not exist: ${candidate_path}")
|
||||
endif()
|
||||
|
||||
file(REAL_PATH "${root_path}" _real_root)
|
||||
file(REAL_PATH "${candidate_path}" _real_candidate)
|
||||
set(_root_for_prefix_check "${_real_root}")
|
||||
cmake_path(
|
||||
IS_PREFIX _root_for_prefix_check "${_real_candidate}"
|
||||
NORMALIZE
|
||||
_is_under_root
|
||||
)
|
||||
if(NOT _is_under_root)
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation rejected ${description}:\n"
|
||||
" resolved path: ${_real_candidate}\n"
|
||||
" required root: ${_real_root}\n"
|
||||
"Provision OpenSSL under UPDATE_CLIENT_THIRDPARTY_ROOT or disable "
|
||||
"UPDATE_CLIENT_STRICT_THIRDPARTY only for local dependency diagnosis."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_assert_openssl_target_under_root target_name root_path)
|
||||
set(_location_properties IMPORTED_LOCATION IMPORTED_IMPLIB)
|
||||
get_target_property(_imported_configurations "${target_name}" IMPORTED_CONFIGURATIONS)
|
||||
if(_imported_configurations AND NOT _imported_configurations MATCHES "-NOTFOUND$")
|
||||
foreach(_configuration IN LISTS _imported_configurations)
|
||||
string(TOUPPER "${_configuration}" _configuration_upper)
|
||||
list(APPEND _location_properties
|
||||
"IMPORTED_LOCATION_${_configuration_upper}"
|
||||
"IMPORTED_IMPLIB_${_configuration_upper}"
|
||||
)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
set(_has_library_location OFF)
|
||||
foreach(_property IN LISTS _location_properties)
|
||||
get_target_property(_locations "${target_name}" "${_property}")
|
||||
if(NOT _locations OR _locations MATCHES "-NOTFOUND$")
|
||||
continue()
|
||||
endif()
|
||||
foreach(_location IN LISTS _locations)
|
||||
_update_client_assert_path_under_root(
|
||||
"${_location}"
|
||||
"${target_name} ${_property}"
|
||||
"${root_path}"
|
||||
)
|
||||
set(_has_library_location ON)
|
||||
endforeach()
|
||||
endforeach()
|
||||
if(NOT _has_library_location)
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation found no imported library location for ${target_name}."
|
||||
)
|
||||
endif()
|
||||
|
||||
get_target_property(_include_directories "${target_name}" INTERFACE_INCLUDE_DIRECTORIES)
|
||||
if(NOT _include_directories OR _include_directories MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation found no imported include directory for ${target_name}."
|
||||
)
|
||||
endif()
|
||||
foreach(_include_directory IN LISTS _include_directories)
|
||||
if(_include_directory MATCHES "^\\$<BUILD_INTERFACE:(.*)>$")
|
||||
set(_include_directory "${CMAKE_MATCH_1}")
|
||||
elseif(_include_directory MATCHES "^\\$<INSTALL_INTERFACE:")
|
||||
continue()
|
||||
endif()
|
||||
_update_client_assert_path_under_root(
|
||||
"${_include_directory}"
|
||||
"${target_name} INTERFACE_INCLUDE_DIRECTORIES"
|
||||
"${root_path}"
|
||||
)
|
||||
endforeach()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_attach_openssl_runtime target_name runtime_dll root_path)
|
||||
_update_client_assert_path_under_root(
|
||||
"${runtime_dll}"
|
||||
"${target_name} runtime DLL"
|
||||
"${root_path}"
|
||||
)
|
||||
set_property(
|
||||
TARGET "${target_name}"
|
||||
PROPERTY UPDATE_CLIENT_RUNTIME_DLL "${runtime_dll}"
|
||||
)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_discover_openssl_runtime)
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_runtime_directory "${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL/bin")
|
||||
if(NOT IS_DIRECTORY "${_runtime_directory}")
|
||||
set(_runtime_directory "${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl/bin")
|
||||
endif()
|
||||
if(NOT IS_DIRECTORY "${_runtime_directory}")
|
||||
message(FATAL_ERROR
|
||||
"Windows OpenSSL runtime directory is missing under "
|
||||
"UPDATE_CLIENT_THIRDPARTY_ROOT: expected OpenSSL/bin. "
|
||||
"Release output requires vendored libssl and libcrypto DLLs."
|
||||
)
|
||||
endif()
|
||||
|
||||
file(GLOB _runtime_candidates CONFIGURE_DEPENDS "${_runtime_directory}/*.dll")
|
||||
set(_ssl_runtime_candidates)
|
||||
set(_crypto_runtime_candidates)
|
||||
foreach(_runtime_candidate IN LISTS _runtime_candidates)
|
||||
cmake_path(GET _runtime_candidate FILENAME _runtime_name)
|
||||
string(TOLOWER "${_runtime_name}" _runtime_name_lower)
|
||||
if(_runtime_name_lower MATCHES "^libssl.*\\.dll$")
|
||||
list(APPEND _ssl_runtime_candidates "${_runtime_candidate}")
|
||||
elseif(_runtime_name_lower MATCHES "^libcrypto.*\\.dll$")
|
||||
list(APPEND _crypto_runtime_candidates "${_runtime_candidate}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
list(LENGTH _ssl_runtime_candidates _ssl_runtime_count)
|
||||
list(LENGTH _crypto_runtime_candidates _crypto_runtime_count)
|
||||
if(NOT _ssl_runtime_count EQUAL 1 OR NOT _crypto_runtime_count EQUAL 1)
|
||||
message(FATAL_ERROR
|
||||
"Windows Release runtime deployment requires exactly one libssl*.dll and "
|
||||
"one libcrypto*.dll in ${_runtime_directory}. Found SSL=${_ssl_runtime_count}, "
|
||||
"Crypto=${_crypto_runtime_count}. Keep only the runtime matching the imported "
|
||||
"OpenSSL package and target architecture."
|
||||
)
|
||||
endif()
|
||||
|
||||
list(GET _ssl_runtime_candidates 0 _ssl_runtime)
|
||||
list(GET _crypto_runtime_candidates 0 _crypto_runtime)
|
||||
_update_client_attach_openssl_runtime(
|
||||
OpenSSL::SSL "${_ssl_runtime}" "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
_update_client_attach_openssl_runtime(
|
||||
OpenSSL::Crypto "${_crypto_runtime}" "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
message(STATUS "update-client OpenSSL SSL runtime: ${_ssl_runtime}")
|
||||
message(STATUS "update-client OpenSSL Crypto runtime: ${_crypto_runtime}")
|
||||
endfunction()
|
||||
|
||||
function(update_client_find_dependencies)
|
||||
_update_client_normalize_thirdparty_root()
|
||||
|
||||
set(_package_prefixes
|
||||
"${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
"${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL"
|
||||
"${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl"
|
||||
)
|
||||
foreach(_prefix IN LISTS _package_prefixes)
|
||||
if(IS_DIRECTORY "${_prefix}")
|
||||
list(PREPEND CMAKE_PREFIX_PATH "${_prefix}")
|
||||
endif()
|
||||
endforeach()
|
||||
list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH)
|
||||
set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE)
|
||||
|
||||
find_package(Qt5 5.15 CONFIG QUIET COMPONENTS Core Gui Network Widgets)
|
||||
if(NOT Qt5_FOUND)
|
||||
message(FATAL_ERROR
|
||||
"Qt 5.15 or newer was not found through its CONFIG package.\n"
|
||||
"The parent project should discover Qt first and pass Qt5_DIR. For a "
|
||||
"standalone build, set Qt5_DIR to the directory containing Qt5Config.cmake "
|
||||
"or add the Qt installation prefix to CMAKE_PREFIX_PATH."
|
||||
)
|
||||
endif()
|
||||
|
||||
if(NOT OPENSSL_ROOT_DIR)
|
||||
if(IS_DIRECTORY "${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL")
|
||||
set(OPENSSL_ROOT_DIR "${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL")
|
||||
elseif(IS_DIRECTORY "${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl")
|
||||
set(OPENSSL_ROOT_DIR "${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl")
|
||||
endif()
|
||||
endif()
|
||||
find_package(OpenSSL QUIET COMPONENTS SSL Crypto)
|
||||
if(NOT OpenSSL_FOUND OR NOT TARGET OpenSSL::SSL OR NOT TARGET OpenSSL::Crypto)
|
||||
message(FATAL_ERROR
|
||||
"OpenSSL with SSL and Crypto imported targets was not found.\n"
|
||||
"Provision OpenSSL under ${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL or set "
|
||||
"OPENSSL_ROOT_DIR to an OpenSSL package inside UPDATE_CLIENT_THIRDPARTY_ROOT. "
|
||||
"Do not pass raw include or library directories."
|
||||
)
|
||||
endif()
|
||||
|
||||
if(UPDATE_CLIENT_STRICT_THIRDPARTY)
|
||||
_update_client_assert_openssl_target_under_root(
|
||||
OpenSSL::SSL "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
_update_client_assert_openssl_target_under_root(
|
||||
OpenSSL::Crypto "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
endif()
|
||||
_update_client_discover_openssl_runtime()
|
||||
|
||||
if(WIN32 AND UPDATE_CLIENT_DEPLOY_QT_RUNTIME)
|
||||
if(NOT TARGET Qt5::qmake)
|
||||
message(FATAL_ERROR
|
||||
"Qt5::qmake is unavailable, so windeployqt cannot be located. "
|
||||
"Use a complete Qt 5 CONFIG package or disable "
|
||||
"UPDATE_CLIENT_DEPLOY_QT_RUNTIME when deployment is handled by the caller."
|
||||
)
|
||||
endif()
|
||||
get_target_property(_qmake_executable Qt5::qmake IMPORTED_LOCATION)
|
||||
cmake_path(GET _qmake_executable PARENT_PATH _qt_binary_dir)
|
||||
find_program(
|
||||
UPDATE_CLIENT_WINDEPLOYQT_EXECUTABLE
|
||||
NAMES windeployqt
|
||||
HINTS "${_qt_binary_dir}"
|
||||
NO_DEFAULT_PATH
|
||||
DOC "Qt deployment tool used by update-client"
|
||||
)
|
||||
if(NOT UPDATE_CLIENT_WINDEPLOYQT_EXECUTABLE)
|
||||
message(FATAL_ERROR
|
||||
"windeployqt was not found next to ${_qmake_executable}. "
|
||||
"Install the Qt deployment tools or configure with "
|
||||
"-DUPDATE_CLIENT_DEPLOY_QT_RUNTIME=OFF when the caller deploys Qt."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(update_client_deploy_openssl_runtime target_name)
|
||||
if(NOT TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Unknown update-client target: ${target_name}")
|
||||
endif()
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
foreach(_openssl_target IN ITEMS OpenSSL::SSL OpenSSL::Crypto)
|
||||
get_target_property(
|
||||
_runtime_dll "${_openssl_target}" UPDATE_CLIENT_RUNTIME_DLL
|
||||
)
|
||||
if(NOT _runtime_dll OR _runtime_dll MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR
|
||||
"${_openssl_target} has no validated UPDATE_CLIENT_RUNTIME_DLL property."
|
||||
)
|
||||
endif()
|
||||
cmake_path(GET _runtime_dll FILENAME _runtime_name)
|
||||
add_custom_command(
|
||||
TARGET "${target_name}"
|
||||
POST_BUILD
|
||||
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
|
||||
"${_runtime_dll}"
|
||||
"$<TARGET_FILE_DIR:${target_name}>/${_runtime_name}"
|
||||
COMMENT "Deploying ${_runtime_name} for ${target_name}"
|
||||
VERBATIM
|
||||
)
|
||||
endforeach()
|
||||
endfunction()
|
||||
|
||||
function(update_client_install_openssl_runtime destination)
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_runtime_dlls)
|
||||
foreach(_openssl_target IN ITEMS OpenSSL::SSL OpenSSL::Crypto)
|
||||
get_target_property(
|
||||
_runtime_dll "${_openssl_target}" UPDATE_CLIENT_RUNTIME_DLL
|
||||
)
|
||||
if(NOT _runtime_dll OR _runtime_dll MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR
|
||||
"${_openssl_target} has no validated runtime DLL for installation."
|
||||
)
|
||||
endif()
|
||||
list(APPEND _runtime_dlls "${_runtime_dll}")
|
||||
endforeach()
|
||||
install(FILES ${_runtime_dlls} DESTINATION "${destination}")
|
||||
endfunction()
|
||||
|
||||
function(update_client_enable_qt_deployment target_name)
|
||||
if(NOT TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Unknown update-client target: ${target_name}")
|
||||
endif()
|
||||
if(NOT WIN32 OR NOT UPDATE_CLIENT_DEPLOY_QT_RUNTIME)
|
||||
return()
|
||||
endif()
|
||||
|
||||
add_custom_command(
|
||||
TARGET "${target_name}"
|
||||
POST_BUILD
|
||||
COMMAND "${UPDATE_CLIENT_WINDEPLOYQT_EXECUTABLE}"
|
||||
"$<IF:$<CONFIG:Debug>,--debug,--release>"
|
||||
"$<TARGET_FILE:${target_name}>"
|
||||
COMMENT "Deploying Qt runtime for ${target_name}"
|
||||
VERBATIM
|
||||
)
|
||||
endfunction()
|
||||
@@ -0,0 +1,889 @@
|
||||
include_guard(DIRECTORY)
|
||||
|
||||
function(_update_client_decode_utf8 codepoints_variable widths_variable input_value)
|
||||
string(HEX "${input_value}" _hex)
|
||||
string(LENGTH "${_hex}" _hex_length)
|
||||
set(_codepoints)
|
||||
set(_widths)
|
||||
set(_offset 0)
|
||||
while(_offset LESS _hex_length)
|
||||
string(SUBSTRING "${_hex}" ${_offset} 2 _first_hex)
|
||||
math(EXPR _first "0x${_first_hex}")
|
||||
if(_first LESS 128)
|
||||
set(_width 1)
|
||||
set(_codepoint ${_first})
|
||||
elseif(_first LESS 224)
|
||||
math(EXPR _second_offset "${_offset} + 2")
|
||||
string(SUBSTRING "${_hex}" ${_second_offset} 2 _second_hex)
|
||||
math(EXPR _second "0x${_second_hex}")
|
||||
math(EXPR _codepoint "((${_first} & 31) << 6) | (${_second} & 63)")
|
||||
set(_width 2)
|
||||
elseif(_first LESS 240)
|
||||
math(EXPR _second_offset "${_offset} + 2")
|
||||
math(EXPR _third_offset "${_offset} + 4")
|
||||
string(SUBSTRING "${_hex}" ${_second_offset} 2 _second_hex)
|
||||
string(SUBSTRING "${_hex}" ${_third_offset} 2 _third_hex)
|
||||
math(EXPR _second "0x${_second_hex}")
|
||||
math(EXPR _third "0x${_third_hex}")
|
||||
math(EXPR _codepoint
|
||||
"((${_first} & 15) << 12) | ((${_second} & 63) << 6) | (${_third} & 63)"
|
||||
)
|
||||
set(_width 3)
|
||||
else()
|
||||
math(EXPR _second_offset "${_offset} + 2")
|
||||
math(EXPR _third_offset "${_offset} + 4")
|
||||
math(EXPR _fourth_offset "${_offset} + 6")
|
||||
string(SUBSTRING "${_hex}" ${_second_offset} 2 _second_hex)
|
||||
string(SUBSTRING "${_hex}" ${_third_offset} 2 _third_hex)
|
||||
string(SUBSTRING "${_hex}" ${_fourth_offset} 2 _fourth_hex)
|
||||
math(EXPR _second "0x${_second_hex}")
|
||||
math(EXPR _third "0x${_third_hex}")
|
||||
math(EXPR _fourth "0x${_fourth_hex}")
|
||||
string(CONCAT _expression
|
||||
"((${_first} & 7) << 18) | ((${_second} & 63) << 12) | "
|
||||
"((${_third} & 63) << 6) | (${_fourth} & 63)"
|
||||
)
|
||||
math(EXPR _codepoint "${_expression}")
|
||||
set(_width 4)
|
||||
endif()
|
||||
list(APPEND _codepoints "${_codepoint}")
|
||||
list(APPEND _widths "${_width}")
|
||||
math(EXPR _offset "${_offset} + (${_width} * 2)")
|
||||
endwhile()
|
||||
set(${codepoints_variable} "${_codepoints}" PARENT_SCOPE)
|
||||
set(${widths_variable} "${_widths}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_codepoint_is_qt_space output_variable codepoint)
|
||||
if((codepoint GREATER_EQUAL 9 AND codepoint LESS_EQUAL 13)
|
||||
OR codepoint EQUAL 32
|
||||
OR codepoint EQUAL 160
|
||||
OR codepoint EQUAL 5760
|
||||
OR (codepoint GREATER_EQUAL 8192 AND codepoint LESS_EQUAL 8202)
|
||||
OR codepoint EQUAL 8232
|
||||
OR codepoint EQUAL 8233
|
||||
OR codepoint EQUAL 8239
|
||||
OR codepoint EQUAL 8287
|
||||
OR codepoint EQUAL 12288)
|
||||
set(${output_variable} TRUE PARENT_SCOPE)
|
||||
else()
|
||||
set(${output_variable} FALSE PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_qt_trim output_variable input_value)
|
||||
_update_client_decode_utf8(_codepoints _widths "${input_value}")
|
||||
list(LENGTH _codepoints _codepoint_count)
|
||||
set(_first_non_space_byte -1)
|
||||
set(_last_non_space_end_byte -1)
|
||||
set(_byte_offset 0)
|
||||
if(_codepoint_count GREATER 0)
|
||||
math(EXPR _last_index "${_codepoint_count} - 1")
|
||||
foreach(_index RANGE 0 ${_last_index})
|
||||
list(GET _codepoints ${_index} _codepoint)
|
||||
list(GET _widths ${_index} _width)
|
||||
_update_client_codepoint_is_qt_space(_is_space "${_codepoint}")
|
||||
if(NOT _is_space)
|
||||
if(_first_non_space_byte EQUAL -1)
|
||||
set(_first_non_space_byte ${_byte_offset})
|
||||
endif()
|
||||
math(EXPR _last_non_space_end_byte "${_byte_offset} + ${_width}")
|
||||
endif()
|
||||
math(EXPR _byte_offset "${_byte_offset} + ${_width}")
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(_first_non_space_byte EQUAL -1)
|
||||
set(_trimmed "")
|
||||
else()
|
||||
math(EXPR _trimmed_length
|
||||
"${_last_non_space_end_byte} - ${_first_non_space_byte}"
|
||||
)
|
||||
string(SUBSTRING "${input_value}" ${_first_non_space_byte} ${_trimmed_length} _trimmed)
|
||||
endif()
|
||||
set(${output_variable} "${_trimmed}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_string_properties
|
||||
control_variable space_variable utf16_length_variable input_value)
|
||||
_update_client_decode_utf8(_codepoints _widths "${input_value}")
|
||||
set(_has_control FALSE)
|
||||
set(_has_space FALSE)
|
||||
set(_utf16_length 0)
|
||||
foreach(_codepoint IN LISTS _codepoints)
|
||||
if(_codepoint LESS_EQUAL 31
|
||||
OR (_codepoint GREATER_EQUAL 127 AND _codepoint LESS_EQUAL 159))
|
||||
set(_has_control TRUE)
|
||||
endif()
|
||||
_update_client_codepoint_is_qt_space(_is_space "${_codepoint}")
|
||||
if(_is_space)
|
||||
set(_has_space TRUE)
|
||||
endif()
|
||||
if(_codepoint GREATER 65535)
|
||||
math(EXPR _utf16_length "${_utf16_length} + 2")
|
||||
else()
|
||||
math(EXPR _utf16_length "${_utf16_length} + 1")
|
||||
endif()
|
||||
endforeach()
|
||||
set(${control_variable} ${_has_control} PARENT_SCOPE)
|
||||
set(${space_variable} ${_has_space} PARENT_SCOPE)
|
||||
set(${utf16_length_variable} ${_utf16_length} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_require_json_string json_text field_name)
|
||||
string(JSON _field_type ERROR_VARIABLE _field_error TYPE "${json_text}" "${field_name}")
|
||||
if(NOT _field_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR
|
||||
"Product config is missing required field '${field_name}': ${_field_error}"
|
||||
)
|
||||
endif()
|
||||
if(NOT _field_type STREQUAL "STRING")
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' must be a string, got ${_field_type}."
|
||||
)
|
||||
endif()
|
||||
|
||||
string(JSON _field_value GET "${json_text}" "${field_name}")
|
||||
_update_client_qt_trim(_trimmed_field_value "${_field_value}")
|
||||
if(_trimmed_field_value STREQUAL "")
|
||||
message(FATAL_ERROR "Product config field '${field_name}' must not be empty.")
|
||||
endif()
|
||||
|
||||
set("UPDATE_CLIENT_CONFIG_${field_name}" "${_field_value}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_require_positive_integer json_text field_name)
|
||||
string(JSON _field_type ERROR_VARIABLE _field_error TYPE "${json_text}" "${field_name}")
|
||||
if(NOT _field_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR
|
||||
"Product config is missing required field '${field_name}': ${_field_error}"
|
||||
)
|
||||
endif()
|
||||
if(NOT _field_type STREQUAL "STRING" AND NOT _field_type STREQUAL "NUMBER")
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' must be a string or number, got ${_field_type}."
|
||||
)
|
||||
endif()
|
||||
string(JSON _field_value GET "${json_text}" "${field_name}")
|
||||
if(NOT _field_value MATCHES "^[1-9][0-9]*$")
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' must contain a positive integer."
|
||||
)
|
||||
endif()
|
||||
string(LENGTH "${_field_value}" _field_length)
|
||||
if(_field_length GREATER 10
|
||||
OR (_field_length EQUAL 10 AND _field_value STRGREATER "2147483647"))
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' exceeds the supported integer range."
|
||||
)
|
||||
endif()
|
||||
set("UPDATE_CLIENT_CONFIG_${field_name}" "${_field_value}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_prepare_qdir_path
|
||||
relative_variable clean_variable absolute_variable input_value)
|
||||
_update_client_qt_trim(_relative "${input_value}")
|
||||
string(REPLACE "\\" "/" _relative "${_relative}")
|
||||
set(_path_for_cmake "${_relative}")
|
||||
cmake_path(IS_ABSOLUTE _path_for_cmake _is_absolute)
|
||||
set(_clean "${_relative}")
|
||||
cmake_path(NORMAL_PATH _clean)
|
||||
if(NOT _clean STREQUAL "/")
|
||||
string(REGEX REPLACE "/+$" "" _clean "${_clean}")
|
||||
endif()
|
||||
if(_clean STREQUAL "")
|
||||
set(_clean ".")
|
||||
endif()
|
||||
set(${relative_variable} "${_relative}" PARENT_SCOPE)
|
||||
set(${clean_variable} "${_clean}" PARENT_SCOPE)
|
||||
set(${absolute_variable} ${_is_absolute} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_install_root output_variable input_value validation_context)
|
||||
_update_client_prepare_qdir_path(_relative _clean _is_absolute "${input_value}")
|
||||
if(_is_absolute OR _relative MATCHES ":" OR NOT _clean STREQUAL _relative)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'install_root' must be a normalized relative path."
|
||||
)
|
||||
endif()
|
||||
set(${output_variable} "${_relative}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_strict_relative_path field_name input_value validation_context)
|
||||
_update_client_prepare_qdir_path(_relative _clean _is_absolute "${input_value}")
|
||||
if(_relative STREQUAL ""
|
||||
OR _is_absolute
|
||||
OR _relative MATCHES ":"
|
||||
OR NOT _clean STREQUAL _relative
|
||||
OR _relative MATCHES "(^|/)\\.\\.?(/|$)")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field '${field_name}' must be a safe relative path."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_split_leading_parents
|
||||
count_variable remainder_variable input_value)
|
||||
set(_remaining "${input_value}")
|
||||
set(_count 0)
|
||||
if(_remaining STREQUAL ".")
|
||||
set(_remaining "")
|
||||
else()
|
||||
while(_remaining MATCHES "^\\.\\./")
|
||||
math(EXPR _count "${_count} + 1")
|
||||
string(SUBSTRING "${_remaining}" 3 -1 _remaining)
|
||||
endwhile()
|
||||
if(_remaining STREQUAL "..")
|
||||
math(EXPR _count "${_count} + 1")
|
||||
set(_remaining "")
|
||||
endif()
|
||||
endif()
|
||||
set(${count_variable} ${_count} PARENT_SCOPE)
|
||||
set(${remainder_variable} "${_remaining}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_main_executable input_value install_root validation_context)
|
||||
_update_client_prepare_qdir_path(_relative _clean _is_absolute "${input_value}")
|
||||
if(_relative STREQUAL ""
|
||||
OR _is_absolute
|
||||
OR _relative MATCHES ":"
|
||||
OR NOT _clean STREQUAL _relative
|
||||
OR _relative STREQUAL "."
|
||||
OR _relative STREQUAL "..")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'main_executable' must stay within install_root."
|
||||
)
|
||||
endif()
|
||||
|
||||
_update_client_split_leading_parents(
|
||||
_install_parent_count _install_remainder "${install_root}"
|
||||
)
|
||||
_update_client_split_leading_parents(
|
||||
_main_parent_count _main_remainder "${_relative}"
|
||||
)
|
||||
set(_is_within_root FALSE)
|
||||
if(_install_remainder STREQUAL "")
|
||||
if(_main_parent_count LESS_EQUAL _install_parent_count)
|
||||
set(_is_within_root TRUE)
|
||||
endif()
|
||||
elseif(_main_parent_count EQUAL _install_parent_count)
|
||||
if(_main_remainder STREQUAL _install_remainder)
|
||||
set(_is_within_root TRUE)
|
||||
else()
|
||||
string(FIND "${_main_remainder}" "${_install_remainder}/" _root_prefix_index)
|
||||
if(_root_prefix_index EQUAL 0)
|
||||
set(_is_within_root TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(NOT _is_within_root)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'main_executable' must stay within install_root."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_count_ipv6_groups count_variable valid_variable input_value)
|
||||
set(_remaining "${input_value}")
|
||||
set(_count 0)
|
||||
set(_valid TRUE)
|
||||
while(NOT _remaining STREQUAL "")
|
||||
string(FIND "${_remaining}" ":" _separator_index)
|
||||
if(_separator_index EQUAL -1)
|
||||
set(_group "${_remaining}")
|
||||
set(_remaining "")
|
||||
else()
|
||||
string(SUBSTRING "${_remaining}" 0 ${_separator_index} _group)
|
||||
math(EXPR _next_index "${_separator_index} + 1")
|
||||
string(SUBSTRING "${_remaining}" ${_next_index} -1 _remaining)
|
||||
endif()
|
||||
string(LENGTH "${_group}" _group_length)
|
||||
if(_group STREQUAL ""
|
||||
OR _group_length GREATER 4
|
||||
OR NOT _group MATCHES "^[0-9A-Fa-f]+$")
|
||||
set(_valid FALSE)
|
||||
break()
|
||||
endif()
|
||||
math(EXPR _count "${_count} + 1")
|
||||
endwhile()
|
||||
set(${count_variable} ${_count} PARENT_SCOPE)
|
||||
set(${valid_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_is_valid_ipv6 output_variable input_value)
|
||||
set(_valid FALSE)
|
||||
if(input_value MATCHES "^[0-9A-Fa-f:]+$")
|
||||
string(FIND "${input_value}" "::" _compression_index)
|
||||
if(_compression_index EQUAL -1)
|
||||
_update_client_count_ipv6_groups(_group_count _groups_valid "${input_value}")
|
||||
if(_groups_valid AND _group_count EQUAL 8)
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
else()
|
||||
string(SUBSTRING "${input_value}" 0 ${_compression_index} _left)
|
||||
math(EXPR _right_index "${_compression_index} + 2")
|
||||
string(SUBSTRING "${input_value}" ${_right_index} -1 _right)
|
||||
string(FIND "${_right}" "::" _second_compression_index)
|
||||
if(NOT _left MATCHES ":$"
|
||||
AND NOT _right MATCHES "^:"
|
||||
AND _second_compression_index EQUAL -1)
|
||||
_update_client_count_ipv6_groups(_left_count _left_valid "${_left}")
|
||||
_update_client_count_ipv6_groups(_right_count _right_valid "${_right}")
|
||||
math(EXPR _explicit_group_count "${_left_count} + ${_right_count}")
|
||||
if(_left_valid AND _right_valid AND _explicit_group_count LESS 8)
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
set(${output_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_is_valid_url_port output_variable input_value)
|
||||
set(_valid FALSE)
|
||||
if(input_value MATCHES "^[0-9]+$")
|
||||
string(REGEX REPLACE "^0+" "" _significant "${input_value}")
|
||||
if(_significant STREQUAL "")
|
||||
set(_significant 0)
|
||||
endif()
|
||||
string(LENGTH "${_significant}" _length)
|
||||
if(_length LESS 6
|
||||
AND NOT (_length EQUAL 5 AND _significant STRGREATER "65535"))
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
set(${output_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_http_url input_value validation_context)
|
||||
set(_valid TRUE)
|
||||
if(NOT input_value MATCHES "^[Hh][Tt][Tt][Pp]([Ss])?://")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
string(REGEX REPLACE
|
||||
"^[Hh][Tt][Tt][Pp]([Ss])?://" "" _url_remainder "${input_value}"
|
||||
)
|
||||
string(REGEX MATCH "^[^/?#]*" _authority "${_url_remainder}")
|
||||
if(_authority STREQUAL "")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
_update_client_string_properties(
|
||||
_url_has_control _unused_space _unused_length "${input_value}"
|
||||
)
|
||||
_update_client_string_properties(
|
||||
_unused_control _authority_has_space _unused_length "${_authority}"
|
||||
)
|
||||
if(_url_has_control OR _authority_has_space)
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(_valid)
|
||||
string(REGEX REPLACE "^.*@" "" _host_port "${_authority}")
|
||||
if(_host_port MATCHES "^\\[")
|
||||
if(NOT _host_port MATCHES "^\\[([^]]+)\\](.*)$")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
set(_host "${CMAKE_MATCH_1}")
|
||||
set(_port_suffix "${CMAKE_MATCH_2}")
|
||||
_update_client_is_valid_ipv6(_host_valid "${_host}")
|
||||
if(NOT _host_valid)
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
endif()
|
||||
else()
|
||||
if(_host_port MATCHES "[\\[\\]]")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
string(REGEX REPLACE "[^:]" "" _colons "${_host_port}")
|
||||
string(LENGTH "${_colons}" _colon_count)
|
||||
if(_colon_count GREATER 1)
|
||||
set(_valid FALSE)
|
||||
elseif(_colon_count EQUAL 1)
|
||||
string(FIND "${_host_port}" ":" _colon_index)
|
||||
string(SUBSTRING "${_host_port}" 0 ${_colon_index} _host)
|
||||
math(EXPR _port_index "${_colon_index} + 1")
|
||||
string(SUBSTRING "${_host_port}" ${_port_index} -1 _port)
|
||||
set(_port_suffix ":${_port}")
|
||||
else()
|
||||
set(_host "${_host_port}")
|
||||
set(_port_suffix "")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(_valid AND _host STREQUAL "")
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
if(_valid AND NOT _port_suffix STREQUAL "")
|
||||
if(NOT _port_suffix MATCHES "^:(.+)$")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
_update_client_is_valid_url_port(_port_valid "${CMAKE_MATCH_1}")
|
||||
if(NOT _port_valid)
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(_valid)
|
||||
_update_client_decode_utf8(_host_codepoints _unused_widths "${_host}")
|
||||
foreach(_codepoint IN LISTS _host_codepoints)
|
||||
if(_codepoint EQUAL 34
|
||||
OR _codepoint EQUAL 35
|
||||
OR _codepoint EQUAL 47
|
||||
OR _codepoint EQUAL 58
|
||||
OR _codepoint EQUAL 60
|
||||
OR _codepoint EQUAL 62
|
||||
OR _codepoint EQUAL 63
|
||||
OR _codepoint EQUAL 64
|
||||
OR _codepoint EQUAL 91
|
||||
OR _codepoint EQUAL 92
|
||||
OR _codepoint EQUAL 93
|
||||
OR _codepoint EQUAL 94
|
||||
OR _codepoint EQUAL 96
|
||||
OR _codepoint EQUAL 123
|
||||
OR _codepoint EQUAL 124
|
||||
OR _codepoint EQUAL 125)
|
||||
set(_valid FALSE)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(NOT _valid)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'api_base_url' must be a valid HTTP or HTTPS URL with a non-empty host."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_is_valid_uuid output_variable input_value)
|
||||
set(_uuid "${input_value}")
|
||||
string(TOLOWER "${_uuid}" _uuid_lower)
|
||||
if(_uuid_lower MATCHES "^urn:uuid:")
|
||||
string(SUBSTRING "${_uuid}" 9 -1 _uuid)
|
||||
endif()
|
||||
if(_uuid MATCHES "^\\{.*\\}$")
|
||||
string(LENGTH "${_uuid}" _uuid_length)
|
||||
if(_uuid_length GREATER_EQUAL 2)
|
||||
math(EXPR _inner_length "${_uuid_length} - 2")
|
||||
string(SUBSTRING "${_uuid}" 1 ${_inner_length} _uuid)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_valid FALSE)
|
||||
if(_uuid STREQUAL "")
|
||||
set(_valid TRUE)
|
||||
else()
|
||||
string(LENGTH "${_uuid}" _uuid_length)
|
||||
if(_uuid_length EQUAL 36 AND _uuid MATCHES "^[0-9A-Fa-f-]+$")
|
||||
string(SUBSTRING "${_uuid}" 8 1 _hyphen_1)
|
||||
string(SUBSTRING "${_uuid}" 13 1 _hyphen_2)
|
||||
string(SUBSTRING "${_uuid}" 18 1 _hyphen_3)
|
||||
string(SUBSTRING "${_uuid}" 23 1 _hyphen_4)
|
||||
string(REPLACE "-" "" _uuid_digits "${_uuid}")
|
||||
string(LENGTH "${_uuid_digits}" _digits_length)
|
||||
string(TOLOWER "${_uuid_digits}" _uuid_digits)
|
||||
if(_hyphen_1 STREQUAL "-"
|
||||
AND _hyphen_2 STREQUAL "-"
|
||||
AND _hyphen_3 STREQUAL "-"
|
||||
AND _hyphen_4 STREQUAL "-"
|
||||
AND _digits_length EQUAL 32
|
||||
AND NOT _uuid_digits STREQUAL "00000000000000000000000000000000")
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
set(${output_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_product_config json_text validation_context)
|
||||
string(JSON _root_type ERROR_VARIABLE _json_error TYPE "${json_text}")
|
||||
if(NOT _json_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR "Invalid JSON in ${validation_context}: ${_json_error}")
|
||||
endif()
|
||||
if(NOT _root_type STREQUAL "OBJECT")
|
||||
message(FATAL_ERROR "Invalid ${validation_context}: root must be a JSON object.")
|
||||
endif()
|
||||
|
||||
string(JSON _schema_type ERROR_VARIABLE _schema_error TYPE "${json_text}" schema_version)
|
||||
if(NOT _schema_error STREQUAL "NOTFOUND" OR NOT _schema_type STREQUAL "NUMBER")
|
||||
message(FATAL_ERROR "Invalid ${validation_context}: schema_version must be the number 1.")
|
||||
endif()
|
||||
string(JSON _schema_version GET "${json_text}" schema_version)
|
||||
if(NOT _schema_version EQUAL 1)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: unsupported schema_version ${_schema_version}."
|
||||
)
|
||||
endif()
|
||||
|
||||
foreach(_required_field IN ITEMS
|
||||
app_id
|
||||
app_name
|
||||
channel
|
||||
launch_token
|
||||
api_base_url
|
||||
client_token
|
||||
temp_folder
|
||||
install_root
|
||||
main_executable
|
||||
launcher_executable
|
||||
updater_executable
|
||||
bootstrap_executable
|
||||
platform
|
||||
arch)
|
||||
_update_client_require_json_string("${json_text}" "${_required_field}")
|
||||
endforeach()
|
||||
foreach(_numeric_field IN ITEMS
|
||||
client_protocol request_timeout_ms health_check_timeout_ms)
|
||||
_update_client_require_positive_integer("${json_text}" "${_numeric_field}")
|
||||
endforeach()
|
||||
|
||||
foreach(_identifier_field IN ITEMS app_id channel platform arch)
|
||||
set(_identifier_value "${UPDATE_CLIENT_CONFIG_${_identifier_field}}")
|
||||
if(NOT _identifier_value MATCHES "^[A-Za-z0-9._-]+$")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field '${_identifier_field}' contains unsupported characters."
|
||||
)
|
||||
endif()
|
||||
endforeach()
|
||||
_update_client_validate_http_url(
|
||||
"${UPDATE_CLIENT_CONFIG_api_base_url}" "${validation_context}"
|
||||
)
|
||||
_update_client_validate_install_root(
|
||||
_install_root "${UPDATE_CLIENT_CONFIG_install_root}" "${validation_context}"
|
||||
)
|
||||
_update_client_validate_main_executable(
|
||||
"${UPDATE_CLIENT_CONFIG_main_executable}" "${_install_root}" "${validation_context}"
|
||||
)
|
||||
foreach(_path_field IN ITEMS
|
||||
temp_folder launcher_executable updater_executable bootstrap_executable)
|
||||
_update_client_validate_strict_relative_path(
|
||||
"${_path_field}" "${UPDATE_CLIENT_CONFIG_${_path_field}}" "${validation_context}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
foreach(_mutable_field IN ITEMS current_version license_key device_id installation_id)
|
||||
string(JSON _root_mutable_type ERROR_VARIABLE _root_mutable_error
|
||||
TYPE "${json_text}" "${_mutable_field}")
|
||||
if(_root_mutable_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: mutable field '${_mutable_field}' must be under initial_state."
|
||||
)
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
string(JSON _initial_state_type ERROR_VARIABLE _initial_state_error
|
||||
TYPE "${json_text}" initial_state)
|
||||
if(NOT _initial_state_error STREQUAL "NOTFOUND"
|
||||
OR NOT _initial_state_type STREQUAL "OBJECT")
|
||||
message(FATAL_ERROR "Invalid ${validation_context}: initial_state must be an object.")
|
||||
endif()
|
||||
set(_allowed_initial_state current_version license_key device_id installation_id)
|
||||
set(_has_current_version FALSE)
|
||||
string(JSON _initial_state_length LENGTH "${json_text}" initial_state)
|
||||
if(_initial_state_length GREATER 0)
|
||||
math(EXPR _initial_state_last "${_initial_state_length} - 1")
|
||||
foreach(_index RANGE 0 ${_initial_state_last})
|
||||
string(JSON _initial_key MEMBER "${json_text}" initial_state ${_index})
|
||||
list(FIND _allowed_initial_state "${_initial_key}" _allowed_index)
|
||||
string(JSON _initial_type TYPE "${json_text}" initial_state "${_initial_key}")
|
||||
if(_allowed_index EQUAL -1 OR NOT _initial_type STREQUAL "STRING")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.${_initial_key} is unsupported or is not a string."
|
||||
)
|
||||
endif()
|
||||
|
||||
string(JSON _initial_value GET "${json_text}" initial_state "${_initial_key}")
|
||||
_update_client_qt_trim(_initial_value "${_initial_value}")
|
||||
if(_initial_key STREQUAL "current_version")
|
||||
set(_has_current_version TRUE)
|
||||
string(LENGTH "${_initial_value}" _version_length)
|
||||
if(_version_length GREATER 128
|
||||
OR NOT _initial_value MATCHES "^[0-9A-Za-z][0-9A-Za-z._+-]*$")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.current_version has an invalid value."
|
||||
)
|
||||
endif()
|
||||
elseif(_initial_key STREQUAL "installation_id")
|
||||
_update_client_is_valid_uuid(_uuid_valid "${_initial_value}")
|
||||
if(NOT _uuid_valid)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.installation_id must be empty or a non-null UUID."
|
||||
)
|
||||
endif()
|
||||
else()
|
||||
_update_client_string_properties(
|
||||
_has_control _unused_space _value_length "${_initial_value}"
|
||||
)
|
||||
if(_initial_key STREQUAL "device_id")
|
||||
set(_maximum_length 256)
|
||||
else()
|
||||
set(_maximum_length 4096)
|
||||
endif()
|
||||
if(_has_control OR _value_length GREATER _maximum_length)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.${_initial_key} contains a control character or exceeds ${_maximum_length} UTF-16 code units."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
if(NOT _has_current_version)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.current_version is required."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_xml_escape output_variable input_value)
|
||||
set(_escaped "${input_value}")
|
||||
string(REPLACE "&" "&" _escaped "${_escaped}")
|
||||
string(REPLACE "<" "<" _escaped "${_escaped}")
|
||||
string(REPLACE ">" ">" _escaped "${_escaped}")
|
||||
set(${output_variable} "${_escaped}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_json_quote output_variable input_value)
|
||||
set(_escaped "${input_value}")
|
||||
string(REPLACE "\\" "\\\\" _escaped "${_escaped}")
|
||||
string(REPLACE "\"" "\\\"" _escaped "${_escaped}")
|
||||
string(REPLACE "\r" "\\r" _escaped "${_escaped}")
|
||||
string(REPLACE "\n" "\\n" _escaped "${_escaped}")
|
||||
string(REPLACE "\t" "\\t" _escaped "${_escaped}")
|
||||
set(${output_variable} "\"${_escaped}\"" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(update_client_select_product_config output_variable)
|
||||
set(_primary_config "${UPDATE_CLIENT_SOURCE_DIR}/config/config.json")
|
||||
set(_fallback_config "${UPDATE_CLIENT_SOURCE_DIR}/config/app_config.example.json")
|
||||
file(GLOB _product_config_candidates CONFIGURE_DEPENDS
|
||||
"${UPDATE_CLIENT_SOURCE_DIR}/config/config.json"
|
||||
"${UPDATE_CLIENT_SOURCE_DIR}/config/app_config.example.json")
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${_primary_config}"
|
||||
"${_fallback_config}"
|
||||
)
|
||||
|
||||
if(UPDATE_CLIENT_PRODUCT_CONFIG_FILE)
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_PRODUCT_CONFIG_FILE
|
||||
BASE_DIRECTORY "${UPDATE_CLIENT_SOURCE_DIR}"
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _selected_config
|
||||
)
|
||||
if(NOT EXISTS "${_selected_config}" OR IS_DIRECTORY "${_selected_config}")
|
||||
message(FATAL_ERROR
|
||||
"UPDATE_CLIENT_PRODUCT_CONFIG_FILE does not exist or is not a file: "
|
||||
"${_selected_config}"
|
||||
)
|
||||
endif()
|
||||
set(_selection_reason "caller-provided UPDATE_CLIENT_PRODUCT_CONFIG_FILE")
|
||||
elseif(EXISTS "${_primary_config}")
|
||||
set(_selected_config "${_primary_config}")
|
||||
set(_selection_reason "project config/config.json")
|
||||
elseif(EXISTS "${_fallback_config}")
|
||||
set(_selected_config "${_fallback_config}")
|
||||
set(_selection_reason "fallback config/app_config.example.json")
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"No update-client product config was found. Create "
|
||||
"${_primary_config} or restore ${_fallback_config}."
|
||||
)
|
||||
endif()
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_selected_config}")
|
||||
set(UPDATE_CLIENT_SELECTED_PRODUCT_CONFIG_FILE
|
||||
"${_selected_config}" CACHE INTERNAL "Selected update-client product config" FORCE)
|
||||
|
||||
file(READ "${_selected_config}" _config_json)
|
||||
_update_client_validate_product_config(
|
||||
"${_config_json}" "selected product config ${_selected_config}"
|
||||
)
|
||||
|
||||
message(STATUS "update-client product config source: ${_selected_config}")
|
||||
message(STATUS "update-client product config selection: ${_selection_reason}")
|
||||
set(${output_variable} "${_selected_config}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(update_client_generate_normalized_product_config output_variable source_file)
|
||||
file(READ "${source_file}" _source_json)
|
||||
string(JSON _normalized_json GET "${_source_json}")
|
||||
|
||||
string(JSON _source_launch_token GET "${_source_json}" launch_token)
|
||||
set(_effective_launch_token "${UPDATE_CLIENT_LAUNCH_TOKEN}")
|
||||
if(_effective_launch_token STREQUAL ""
|
||||
AND _source_launch_token MATCHES "(ChangeMe|CHANGE_ME|YOUR_[A-Z_]+)")
|
||||
if(NOT UPDATE_CLIENT_GENERATED_LAUNCH_TOKEN)
|
||||
string(RANDOM LENGTH 64
|
||||
ALPHABET "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
_generated_launch_token)
|
||||
set(UPDATE_CLIENT_GENERATED_LAUNCH_TOKEN "${_generated_launch_token}"
|
||||
CACHE INTERNAL "Generated update-client launch token" FORCE)
|
||||
endif()
|
||||
set(_effective_launch_token "${UPDATE_CLIENT_GENERATED_LAUNCH_TOKEN}")
|
||||
message(STATUS "Generated a build-local launch token for the example product config")
|
||||
endif()
|
||||
if(NOT _effective_launch_token STREQUAL "")
|
||||
_update_client_json_quote(_launch_token_json "${_effective_launch_token}")
|
||||
string(JSON _normalized_json SET "${_normalized_json}"
|
||||
launch_token "${_launch_token_json}")
|
||||
endif()
|
||||
|
||||
foreach(_override_variable IN ITEMS
|
||||
UPDATE_CLIENT_MAIN_EXECUTABLE
|
||||
UPDATE_CLIENT_LAUNCHER_EXECUTABLE
|
||||
UPDATE_CLIENT_UPDATER_EXECUTABLE
|
||||
UPDATE_CLIENT_BOOTSTRAP_EXECUTABLE
|
||||
UPDATE_CLIENT_PLATFORM
|
||||
UPDATE_CLIENT_ARCH)
|
||||
if("${${_override_variable}}" STREQUAL "")
|
||||
continue()
|
||||
endif()
|
||||
|
||||
string(REGEX REPLACE "^UPDATE_CLIENT_" "" _json_field "${_override_variable}")
|
||||
string(TOLOWER "${_json_field}" _json_field)
|
||||
_update_client_json_quote(_json_value "${${_override_variable}}")
|
||||
string(JSON _normalized_json SET "${_normalized_json}" "${_json_field}" "${_json_value}")
|
||||
message(STATUS
|
||||
"update-client product config override: ${_json_field}=${${_override_variable}}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
_update_client_validate_product_config(
|
||||
"${_normalized_json}" "normalized product config after build overrides"
|
||||
)
|
||||
|
||||
set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${_generated_dir}")
|
||||
set(_normalized_file "${_generated_dir}/product-config.json")
|
||||
file(WRITE "${_normalized_file}" "${_normalized_json}\n")
|
||||
message(STATUS "update-client normalized product config: ${_normalized_file}")
|
||||
set(${output_variable} "${_normalized_file}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(update_client_create_embedded_resources target_name product_config_file)
|
||||
if(TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Embedded resource target already exists: ${target_name}")
|
||||
endif()
|
||||
|
||||
set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${_generated_dir}")
|
||||
|
||||
cmake_path(CONVERT "${product_config_file}" TO_CMAKE_PATH_LIST _config_qrc_path NORMALIZE)
|
||||
_update_client_xml_escape(_config_qrc_path "${_config_qrc_path}")
|
||||
set(_resource_entries
|
||||
" <file alias=\"product-config.json\">${_config_qrc_path}</file>\n"
|
||||
)
|
||||
|
||||
set(_public_key "${UPDATE_CLIENT_SOURCE_DIR}/config/manifest_public_key.pem")
|
||||
if(NOT EXISTS "${_public_key}")
|
||||
message(FATAL_ERROR
|
||||
"The manifest verification public key is missing: ${_public_key}"
|
||||
)
|
||||
endif()
|
||||
cmake_path(CONVERT "${_public_key}" TO_CMAKE_PATH_LIST _public_key_qrc_path NORMALIZE)
|
||||
_update_client_xml_escape(_public_key_qrc_path "${_public_key_qrc_path}")
|
||||
string(APPEND _resource_entries
|
||||
" <file alias=\"manifest-public-key.pem\">${_public_key_qrc_path}</file>\n"
|
||||
)
|
||||
|
||||
set(_translation_catalog
|
||||
"${UPDATE_CLIENT_SOURCE_DIR}/translations/update-client_zh_CN.ts"
|
||||
)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_translation_catalog}")
|
||||
set(_generated_inputs)
|
||||
if(EXISTS "${_translation_catalog}")
|
||||
find_package(Qt5 5.15 CONFIG REQUIRED COMPONENTS LinguistTools)
|
||||
if(NOT TARGET Qt5::lrelease)
|
||||
message(FATAL_ERROR
|
||||
"Qt5::lrelease is required to compile update-client translations."
|
||||
)
|
||||
endif()
|
||||
|
||||
set(_translation_qm "${_generated_dir}/update-client_zh_CN.qm")
|
||||
add_custom_command(
|
||||
OUTPUT "${_translation_qm}"
|
||||
COMMAND Qt5::lrelease "${_translation_catalog}" -qm "${_translation_qm}"
|
||||
DEPENDS "${_translation_catalog}" Qt5::lrelease
|
||||
COMMENT "Compiling update-client Chinese translation catalog"
|
||||
VERBATIM
|
||||
)
|
||||
list(APPEND _generated_inputs "${_translation_qm}")
|
||||
cmake_path(CONVERT "${_translation_qm}" TO_CMAKE_PATH_LIST _translation_qrc_path NORMALIZE)
|
||||
_update_client_xml_escape(_translation_qrc_path "${_translation_qrc_path}")
|
||||
string(APPEND _resource_entries
|
||||
" <file alias=\"i18n/update-client_zh_CN.qm\">${_translation_qrc_path}</file>\n"
|
||||
)
|
||||
elseif(UPDATE_CLIENT_REQUIRE_TRANSLATIONS)
|
||||
message(FATAL_ERROR
|
||||
"Required translation catalog is missing: ${_translation_catalog}"
|
||||
)
|
||||
else()
|
||||
message(STATUS
|
||||
"Translation catalog is not present yet; embedded i18n will be enabled when "
|
||||
"translations/update-client_zh_CN.ts is added."
|
||||
)
|
||||
endif()
|
||||
|
||||
set(_resource_file "${_generated_dir}/update_client_embedded.qrc")
|
||||
string(CONCAT _resource_content
|
||||
"<RCC>\n"
|
||||
" <qresource prefix=\"/simcae/update-client\">\n"
|
||||
"${_resource_entries}"
|
||||
" </qresource>\n"
|
||||
"</RCC>\n"
|
||||
)
|
||||
file(CONFIGURE
|
||||
OUTPUT "${_resource_file}"
|
||||
CONTENT "${_resource_content}"
|
||||
@ONLY
|
||||
)
|
||||
|
||||
if(NOT TARGET Qt5::rcc)
|
||||
message(FATAL_ERROR "Qt5::rcc is required to compile update-client resources.")
|
||||
endif()
|
||||
set(_resource_source "${_generated_dir}/qrc_update_client_embedded.cpp")
|
||||
add_custom_command(
|
||||
OUTPUT "${_resource_source}"
|
||||
COMMAND Qt5::rcc
|
||||
--name update_client_embedded
|
||||
--output "${_resource_source}"
|
||||
"${_resource_file}"
|
||||
DEPENDS
|
||||
"${_resource_file}"
|
||||
"${product_config_file}"
|
||||
"${_public_key}"
|
||||
${_generated_inputs}
|
||||
Qt5::rcc
|
||||
COMMENT "Compiling update-client embedded resources"
|
||||
VERBATIM
|
||||
)
|
||||
add_library("${target_name}" OBJECT "${_resource_source}" ${_generated_inputs})
|
||||
target_link_libraries("${target_name}" PRIVATE Qt5::Core)
|
||||
set_target_properties("${target_name}" PROPERTIES
|
||||
AUTOMOC OFF
|
||||
AUTOUIC OFF
|
||||
AUTORCC OFF
|
||||
)
|
||||
|
||||
set(UPDATE_CLIENT_PRODUCT_CONFIG_RESOURCE
|
||||
":/simcae/update-client/product-config.json"
|
||||
PARENT_SCOPE
|
||||
)
|
||||
set(UPDATE_CLIENT_TRANSLATION_RESOURCE
|
||||
":/simcae/update-client/i18n/update-client_zh_CN.qm"
|
||||
PARENT_SCOPE
|
||||
)
|
||||
endfunction()
|
||||
|
||||
function(update_client_link_embedded_resources target_name)
|
||||
if(NOT TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Unknown update-client target: ${target_name}")
|
||||
endif()
|
||||
if(NOT TARGET UpdateClientEmbeddedResources)
|
||||
message(FATAL_ERROR "UpdateClientEmbeddedResources has not been created.")
|
||||
endif()
|
||||
target_link_libraries("${target_name}" PRIVATE UpdateClientEmbeddedResources)
|
||||
endfunction()
|
||||
@@ -1,16 +1,14 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"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": "",
|
||||
"api_base_url": "http://YOUR_SERVER_IP:8000",
|
||||
"client_token": "SimCAEClientToken2026",
|
||||
"request_timeout_ms": "5000",
|
||||
"temp_folder": "update_temp",
|
||||
"device_id": "",
|
||||
"install_root": "..",
|
||||
"main_executable": "SimCAE.exe",
|
||||
"launcher_executable": "Launcher.exe",
|
||||
@@ -18,5 +16,11 @@
|
||||
"bootstrap_executable": "Bootstrap.exe",
|
||||
"health_check_timeout_ms": "15000",
|
||||
"platform": "windows",
|
||||
"arch": "x64"
|
||||
"arch": "x64",
|
||||
"initial_state": {
|
||||
"current_version": "1.0.0",
|
||||
"license_key": "",
|
||||
"device_id": "",
|
||||
"installation_id": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# 更新客户端架构
|
||||
|
||||
## 目的
|
||||
|
||||
更新客户端强制执行一条受控的产品入口路径,并执行事务性更新,而不会将可编辑的安装文件作为信任边界的一部分。父项目 SimCAE 拥有集成和交付的控制权。
|
||||
|
||||
本文档描述了运行时合约。服务器发布、管理员界面、数据库模式和对象存储实现均在此仓库之外。
|
||||
|
||||
## 产品入口规则
|
||||
|
||||
在每个支持的操作系统上,必须通过 `Launcher` 进入发布版本。如果某个平台尚未提供可用的启动器和更新运行时,其发布配置或打包必须失败。直接启动应用程序不是可接受的备用方案。
|
||||
|
||||
调试版本可以禁用启动门以供开发者迭代使用。但带有此例外的构建版本不得分发。
|
||||
|
||||
## 组件
|
||||
|
||||
### 启动器
|
||||
|
||||
`启动器` 是面向用户的入口。它:
|
||||
|
||||
1. 加载嵌入式产品配置和机器范围的可变状态。
|
||||
2. 建立或验证设备凭证。
|
||||
3. 加载并验证已签名的版本策略。
|
||||
4. 检查防回滚、系统时钟倒退和离线有效性条件。
|
||||
5. 将已安装的版本与已签名的清单进行比对验证。
|
||||
6. 在有网络连接时检查服务器是否允许更新。
|
||||
7. 当需要更新时启动 `Updater`。
|
||||
8. 否则创建一个短期有效的一次性票据并启动 SimCAE。
|
||||
|
||||
在需要提升权限才能进行机器范围状态或安装更新的平台上,发布启动器必须通过平台原生机制请求所需权限。
|
||||
|
||||
### 主程序
|
||||
|
||||
交付的 SimCAE 可执行文件实现了合同中的主应用程序部分。此仓库中的 `MainApp` 是一个集成示例,不是第二个产品可执行文件。
|
||||
|
||||
该应用程序必须:
|
||||
|
||||
* 在正常启动前消耗并验证 `--ticket-file=<path>`;
|
||||
* 拒绝在受控发布构建中缺失、过期、重复或不匹配的票证;
|
||||
* 仅在启动达到更新器预期的健康点后,将 `ok\n` 写入 `--health-file=<path>`。
|
||||
|
||||
在当前集成中,启动器拥有已签名策略和已安装文件完整性检查。应用程序独立验证短期票证;将策略或清单验证移至 SimCAE 需要单独的共享运行时协议,且不得由本文件暗示。
|
||||
|
||||
启动票证阻止了对 `Launcher` 的随意绕过。它不是已签名策略、清单验证或服务器授权的替代品。
|
||||
|
||||
### 更新器
|
||||
|
||||
`Updater` 拥有更新事务。它:
|
||||
|
||||
* 获取已签名的目标清单和授权下载位置;
|
||||
* 在进行任何文件操作前验证路径;
|
||||
* 比较本地文件与完整清单;
|
||||
* 仅下载缺失或不匹配的文件;
|
||||
* 在安装前验证签名、大小和哈希值;
|
||||
* 备份被替换或删除的文件;
|
||||
* 安装阶段内容;
|
||||
* 在必要时将锁定文件的替换委托给 `Bootstrap`;
|
||||
* 使用启动票和健康文件请求启动更新后的应用程序;
|
||||
* 仅在健康确认后提交;
|
||||
* 在失败时恢复上一版本。
|
||||
|
||||
下载可以是增量的,但完整性验证始终针对完整的签名清单。
|
||||
|
||||
### 引导启动
|
||||
|
||||
`Bootstrap` 有意设计得非常小。它会等待更新程序进程释放被锁定的文件,执行已批准的替换方案,并重新启动更新程序以继续验证。它必须拒绝不安全的相对路径,且不得擅自制定更新策略。
|
||||
|
||||
### 通用运行时
|
||||
|
||||
共享库提供配置访问、原生状态、设备标识、HTTP 请求、签名策略处理、本地防回滚状态、清单完整性验证以及启动票操作。共享辅助工具不会削弱组件特定的检查。
|
||||
|
||||
## 启动序列
|
||||
|
||||
```text
|
||||
用户
|
||||
-> 启动器
|
||||
-> 嵌入式产品配置
|
||||
-> 机器状态和设备凭证
|
||||
-> 签名策略和清单验证
|
||||
-> 是否需要更新? -> 更新器 -> 需要时启动引导程序
|
||||
-> 创建一次性票据
|
||||
-> SimCAE
|
||||
-> 消耗票据
|
||||
-> 重新验证策略和完整性
|
||||
-> 按请求报告启动健康状态
|
||||
```
|
||||
|
||||
设备激活是机器识别,而不是终端用户登录。普通客户端启动不会创建管理员会话,也不应接收服务器的发布权限。
|
||||
|
||||
## 更新事务
|
||||
|
||||
一个持久的事务必须至少区分这些阶段:
|
||||
|
||||
1. 初始化事务标识符和暂存区。
|
||||
2. 验证目标清单和所有暂存的负载。
|
||||
3. 记录已更改和过时的路径。
|
||||
4. 备份当前文件。
|
||||
5. 安装未被锁定的文件。
|
||||
6. 当需要时,将锁定文件的工作交给 `Bootstrap`。
|
||||
7. 启动候选版本并等待其健康标记。
|
||||
8. 提交并删除临时数据仅在成功验证后进行。
|
||||
9. 在任何失败后回滚并验证恢复的版本。
|
||||
|
||||
在进程启动时,必须在开始新事务之前恢复中断的事务。在持久化状态仍需要恢复时,不得删除暂存和备份数据。
|
||||
|
||||
## 信任边界
|
||||
|
||||
| 数据或机制 | 权限 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 服务器私有签名密钥 | 仅限服务器 | 从未运送到客户。 |
|
||||
| 已签署的版本策略 | 服务器 | 控制运行、更新、降级和离线决策。 |
|
||||
| 签名的清单 | 服务器 | 定义了完整的受保护文件集合和预期的哈希值。 |
|
||||
| 签名的设备凭证 | 服务器 | 绑定应用程序、频道、安装、设备和许可证身份。它不是用户会话。 |
|
||||
| 嵌入式产品配置 | 构建流水线 | 防止随意覆盖安装文件;可由管理员检查和修改。 |
|
||||
| 原生可变状态 | 本地机器 | 保持注册和安装状态;它不涉及授权。 |
|
||||
| 一次性启动票 | 本地启动器 | 证明预期的启动器启动了此进程。它不授予服务器权限。 |
|
||||
| 健康标识 | 候选流程 | 信号提交或回滚的启动完成;它不能证明包的真实性。 |
|
||||
|
||||
带签名的 JSON 合约必须使用确定性序列化,从已签名的负载中省略签名字段,标识算法和密钥 ID,并支持受控的公钥轮换窗口。验证失败将导致系统关闭。
|
||||
|
||||
## 运行时布局
|
||||
|
||||
父安装程序拥有最终的布局。预期的可执行文件关系为:
|
||||
|
||||
```text
|
||||
<install-root>/
|
||||
bin/
|
||||
Launcher<platform-suffix>
|
||||
Updater<platform-suffix>
|
||||
Bootstrap<platform-suffix>
|
||||
SimCAE<platform-suffix>
|
||||
config/
|
||||
<仅在需要时缓存已签名的运行时>
|
||||
```
|
||||
|
||||
安装目录中不应包含可编辑的产品策略 JSON 文件。更新缓存、备份、已签名策略缓存和机器状态必须使用集成运行时选择的位置,并且必须遵守平台的写入权限规则。
|
||||
|
||||
## 失败策略
|
||||
|
||||
* 无效签名、不安全路径、身份不匹配、策略回滚以及受保护文件损坏将导致系统关闭。
|
||||
* 一个临时的网络故障可能会使用仍然有效的已签名离线策略;它不能静默地创建新的允许项。
|
||||
* 过期的离线策略或被禁止的版本会阻止启动。
|
||||
* 失败的候选健康触发器会导致回滚。
|
||||
* 回滚失败会留下可恢复的状态和诊断错误;它不能报告更新成功。
|
||||
|
||||
## 接受场景
|
||||
|
||||
发布接受必须涵盖:
|
||||
|
||||
* 通过 `Launcher` 正常启动;
|
||||
* 直接 SimCAE 启动拒绝;
|
||||
* 首次设备注册及后续离线启动;
|
||||
* 更新到较新的已发布版本;
|
||||
* 篡改的受保护文件被拒绝;
|
||||
* 篡改的策略或清单被拒绝;
|
||||
* 强制更新和禁用版本行为;
|
||||
* 频道选择和禁止降级行为;
|
||||
* 中断更新恢复;
|
||||
* 候选失败和成功回滚;
|
||||
* 从实际安装位置启动并更新,通过正常的最终用户交互并需要提升权限。
|
||||
@@ -0,0 +1,123 @@
|
||||
# 构建和打包
|
||||
|
||||
## 所有权
|
||||
|
||||
父级 SimCAE 仓库拥有发布配置、运行时部署、安装程序元数据以及最终的用户包。此子项目提供运行时目标和可安装的公共资源。
|
||||
|
||||
支持的包目标是 `package_installer`。它使用父级 CMake 安装规则和 Qt Installer Framework。 `package_installer_qt` 是一个归档的实验性路径,不得使用,也不应作为后备方案进行修复或作为支持的交付选项进行文档说明。
|
||||
|
||||
移除的 `install-sdk.ps1`、`package-client.ps1` 和 `package-sdk.ps1` 工作流不被支持。它们在父级外部组装了部分树 ,这可能导致与产品运行时闭包不一致。
|
||||
|
||||
## 前提条件
|
||||
|
||||
发布构建需要:
|
||||
|
||||
* 与父项目兼容的 CMake 和编译器版本;
|
||||
* 更新客户端目标所请求的 Qt 模块;
|
||||
* 通过 CMake 导入目标发现的 OpenSSL
|
||||
* Qt Installer Framework for `package_installer`;和
|
||||
* 父仓库中批准的 `3rdparty` 包或由调用者明确提供的 CMake 包提示。
|
||||
|
||||
仓库的 CMake 文件不得包含指向工作站的绝对路径,强制 调用者的生成器或架构,或手动选择 Debug 和 Release 库目录。依赖项发现必须生成导入的目标,例如 `Qt5::Core`、`OpenSSL::SSL` 和 `OpenSSL::Crypto`。
|
||||
|
||||
缺少已批准的包必须停止配置并显示可操作的错误。发布包不得静默回退到不相关的系统包。
|
||||
|
||||
## 父级发布构建
|
||||
|
||||
从 SimCAE 仓库根目录运行集成工作流:
|
||||
|
||||
```powershell
|
||||
git submodule update --init --recursive
|
||||
cmake --preset SimCAE-release
|
||||
cmake --build --preset SimCAE-release --target SimCAE
|
||||
```
|
||||
|
||||
专用的发布预设将发布依赖项和输出分开 来自调试信息和旧的混合构建树。集成构建生成 `Launcher`、`Updater` 和 `Bootstrap` 位于 SimCAE 相同的运行目录中,并将发布启动门构建到 SimCAE 中。
|
||||
|
||||
调试开发使用 Debug 预设:
|
||||
|
||||
```powershell
|
||||
cmake --preset SimCAE-debug
|
||||
cmake --build --preset SimCAE-debug --target SimCAE
|
||||
```
|
||||
|
||||
调试模式可能允许开发者直接启动 SimCAE 进行迭代开发。请勿从调试或混合构建树中验证或打包发布版本。
|
||||
|
||||
## 测试
|
||||
|
||||
在打包之前运行父级发布测试:
|
||||
|
||||
```powershell
|
||||
ctest --test-dir out/build/SimCAE-release -C Release --output-on-failure
|
||||
```
|
||||
|
||||
聚焦的更新客户端测试可能运行得更早,但它们不会替代父级测试套件、安装树检查或真实机器验收。
|
||||
|
||||
## 安装程序
|
||||
|
||||
从同一配置的 Release 树构建唯一支持的用户交付版本:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset SimCAE-release --target package_installer
|
||||
```
|
||||
|
||||
该目标通过 `cmake --install` 阶段文件,根据父包定义分离可选的产品组件,并调用 Qt Installer Framework。生成的安装程序以配置的产品版本和平台名称写入 Release 构建目录中。
|
||||
|
||||
服务器发布仍然是一个显式的发布操作。构建安装程序不会创建或发布服务器版本、策略、清单或许可证。
|
||||
|
||||
## 独立开发者构建
|
||||
|
||||
一个独立的更新客户端构建对于集中编译和演示测试很有用。它不是最终用户包。
|
||||
|
||||
从父仓库根目录:
|
||||
|
||||
```powershell
|
||||
cmake -S update-client -B out/build/update-client-release
|
||||
cmake --build out/build/update-client-release --config Release `
|
||||
--target Launcher Updater Bootstrap MainApp
|
||||
```
|
||||
|
||||
默认情况下,子项目会在父仓库的 `3rdparty` 目录中查找。对于不同的批准布局,请传递 更新客户端第三方库根目录;标准的包特定 CMake 提示仍可用于专注的开发者构建。请保留所有未提交到 CMake 文件中的值。不要在父应用程序选择的运行时上覆盖第二个 Qt 运行时。
|
||||
|
||||
独立输出是临时的开发者输出。不要将其压缩或发送给用户。
|
||||
|
||||
## 包接受检查清单
|
||||
|
||||
在交付前,请对照干净的安装阶段验证以下所有内容:
|
||||
|
||||
* Launcher,Updater,Bootstrap 和 SimCAE 存在于预期的运行时目录中。
|
||||
* 已安装的用户入口点和快捷方式启动 Launcher,而不是 SimCAE。
|
||||
* 在 Release 版本中拒绝直接启动 SimCAE。
|
||||
* 未安装 app\_config.json,client.ini,源 config.json 或示例产品配置。
|
||||
* 公开验证材料和嵌入式翻译资源存在。
|
||||
* 没有 PDB、ILK、带有 Debug 后缀的 Qt 运行时或其他 Debug 产物。
|
||||
* 在预期的安装路径下存在一个且仅有一个 SimCAE 可执行文件。
|
||||
* 运行时依赖闭包来源于配置的 Release 包;没有 DLL 或共享库解析到开发工作站路径。
|
||||
* 受保护的文件与已签名的当前版本清单文件匹配。如果支持离线首次启动,其已签名的策略和清单缓存都存在且有效。
|
||||
* 开发机器上的可变状态不存在。
|
||||
* 在 `Launcher` 旁边放置一个伪造的 `app_config.json` 文件无法更改应用程序身份、渠道、端点、可执行策略或票务验证。
|
||||
* 安装、启动、更新、回滚和卸载均从真实安装位置执行。
|
||||
|
||||
## 运行时兼容性
|
||||
|
||||
更新运行时和 SimCAE 共享已部署的 Qt 和编译器运行时。打包更改不得用不同的 Qt 构建覆盖该运行时。Qt 库中的入口点错误通常表示混合运行时,而不是缺少配置文件。
|
||||
|
||||
对于提升系统安装,首先验证首次注册和状态写入,而不应授予整个应用程序目录的写入权限。运行时状态应存储在本机机器存储或批准的数据位置,而不是放在可执行文件旁边。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 目标包缺失
|
||||
|
||||
确认在配置过程中已找到 Qt Installer Framework,并检查配置输出。不要切换到 `package_installer_qt`。
|
||||
|
||||
### 发布版本加载调试库
|
||||
|
||||
使用专用的发布预设重新配置,并检查所选导入的目标。不要重命名调试二进制文件或在产品树中添加别名。
|
||||
|
||||
### 设备注册无法持久化
|
||||
|
||||
确认 `Launcher` 具备用于机器范围状态的平台权限,并且已安装的构建正在使用原生状态后端。不要将安装目录中的 `app_config.json` 写入作为变通方法。
|
||||
|
||||
### 更新应用程序回滚
|
||||
|
||||
检查更新事务的诊断信息,并确认候选进程在配置的超时时间之前已写入请求的健康标记。绝不要强制提交未经验证的候选进程。
|
||||
@@ -0,0 +1,135 @@
|
||||
# 配置与安全
|
||||
|
||||
## 配置模型
|
||||
|
||||
配置分为两个拥有不同所有者的类别:
|
||||
|
||||
1. **产品配置**是在配置时选择和验证的。它被嵌入到运行时中,并在二进制文件构建完成后变为只读。
|
||||
2. **机器状态**在产品安装过程中会发生变化。它由原生系统范围的后端存储,并且永远不会重新定义产品策略。
|
||||
|
||||
此拆分将可编辑的 `app_config.json` 从已安装的信任路径中移除。
|
||||
|
||||
## 配置时源选择
|
||||
|
||||
自动选择使用以下顺序:
|
||||
|
||||
1. `config/config.json`,当该文件存在时。
|
||||
2. `config/app_config.example.json`,否则。
|
||||
|
||||
集成构建可能会通过 `UPDATE_CLIENT_PRODUCT_CONFIG_FILE` 传递一个确切的文件;该显式输入具有优先权,必须由配置输出报告。清除缓存变量可恢复自动选择。
|
||||
|
||||
`config/config.json` 是一个本地或发布流水线输入。它必须保持未跟踪状态。示例是一个可构建的开发备用方案,而不是生产密钥存储。
|
||||
|
||||
配置必须在所选文件格式错误、缺少必需值、值类型错误或路径违反运行时布局协议时失败。所选的 JSON 文件将被规范化为生成的资源并嵌入到 qrc 中。源 JSON 文件和生成的中间文件不得安装。
|
||||
|
||||
## 产品配置
|
||||
|
||||
产品配置包括定义二进制文件被允许运行的产品的值:
|
||||
|
||||
* application ID 和显示名称;
|
||||
* 发布渠道和客户端协议;
|
||||
* API 基础 URL 和非特权客户端令牌;
|
||||
* 启动票验证材料;
|
||||
* 可执行角色名称和安装布局;
|
||||
* 更新临时目录策略;
|
||||
* 请求和健康检查超时;以及
|
||||
* 目标平台和架构。
|
||||
|
||||
所选的源也包含一个 `initial_state` 对象。它仅提供以下可变键的首次运行默认值。它不会将这些键变为不可变策略,之后的运行时更改仍保留在本地状态存储中。
|
||||
|
||||
这些值可能在不同构建之间有所不同,但安装的文件不得覆盖它们。在可执行文件旁边放置名为 `app_config.json`、`client.ini` 或 `config.json` 的文件,不是受支持的运行时覆盖。
|
||||
|
||||
可执行文件路径必须是相对的、规范化的,并且限定在批准的安装根目录内。它们不得包含遍历段,也不得指向产品外部的任意可执行文件。
|
||||
|
||||
## 可变机器状态
|
||||
|
||||
机器状态包括:
|
||||
|
||||
* 当前安装的版本;
|
||||
* 许可证注册值;
|
||||
* 安装和设备标识符;
|
||||
* 迁移完成标记;
|
||||
* 单调更新或策略状态未包含在已签名的缓存中。
|
||||
|
||||
Qt 客户端在系统范围内使用原生后端存储这些值,并禁用回退查找。在 Windows 上,这是机器级别的注册表位置。其他平台使用等效的原生系统位置。当前逻辑命名空间是 `SimCAE/UpdateClient`,产品状态如下 `products/<app-id>/<channel>/state/`,因此所有更新客户端进程都访问同一个存储,而不会混合产品或频道。
|
||||
|
||||
发布启动必须具有读取和更新此状态所需的平台权限。未能持久化所需状态会导致启动错误;不得触发回退到可执行目录 JSON。
|
||||
|
||||
已签名的策略、清单和设备凭证缓存,在其格式需要时可以使用经批准的数据文件。此类文件是运行时缓存,而非产品配置,其签名在使用前必须进行验证。
|
||||
|
||||
## 版本和发布边界
|
||||
|
||||
已安装的版本是本地状态。服务器版本、通道、策略和发布清单是通过服务器工作流程显式发布的。本地构建、Git 版本、头信息值或安装程序文件名不得自动发布或授权服务器发布。
|
||||
|
||||
客户端只能在提交并验证的更新事务的一部分中报告其已安装的版本并将其向前推进。
|
||||
|
||||
## 安全属性
|
||||
|
||||
### 嵌入数据不是机密
|
||||
|
||||
qrc 可防止对侧边 JSON 文件进行随意编辑。它不会阻止机器管理员进行二进制检查或修改。因此,客户端中嵌入的值不得包含服务器发布或管理权限。
|
||||
|
||||
API 客户端令牌和启动票据材料是客户端凭证。服务器端点仍必须对每个操作进行身份验证,并独立授权设备、许可证、频道和版本。
|
||||
|
||||
### 原生状态不是信任根
|
||||
|
||||
系统范围的原生存储提高了所有权和访问控制,尤其是在系统安装中。管理员仍然可以对其进行修改。本地版本、许可证文本或迁移标志不能替代服务器验证。
|
||||
|
||||
### 签名的服务器数据具有权威性
|
||||
|
||||
服务器私钥从不会离开服务器。客户端仅接收公开验证材料。客户端会验证所有适用的签名工件,包括:
|
||||
|
||||
* 版本策略;
|
||||
* 完整文件清单;
|
||||
* 设备凭证;和
|
||||
* 离线更新元数据,如支持。
|
||||
|
||||
签名的有效负载标识其使用的算法和密钥 ID,并采用确定性的序列化方式。密钥轮换可能会保留当前和立即前一个公钥,以便在受控的迁移窗口期内进行过渡。未知密钥或无效签名将关闭失败。
|
||||
|
||||
### 设备身份不等于用户登录
|
||||
|
||||
设备发行将应用程序、频道、安装、设备和许可证绑定在一起。它不会创建终端用户会话,也不会授予对管理发布接口的访问权限。
|
||||
|
||||
### 启动票证作用范围
|
||||
|
||||
一次性票券绑定预期的应用、设备、版本和较短的有效期。应用会一次性消耗该票券。票券验证阻止普通直接启动,但不会使策略或完整性检查变得可选。
|
||||
|
||||
### 完整性与回滚
|
||||
|
||||
更新程序可能仅下载更改的文件,但受保护的安装会针对完整的签名清单进行验证。在文件操作前会检查路径。先前版本会在候选版本健康状态确认之前进行备份,并在另一次更新前恢复中断的事务。
|
||||
|
||||
## 公开验证材料
|
||||
|
||||
公钥不是秘密。它们可以作为显式的公共运行时资产嵌入或安装,前提是包拥有其来源,并且客户端不会在没有其他可信锚点的情况下接受任意替换。私钥和服务器管理凭证绝不能进入此仓库或安装程序。
|
||||
|
||||
## 发布包规则
|
||||
|
||||
已安装的树中不得包含:
|
||||
|
||||
* app\_config.json;
|
||||
* `client.ini`;
|
||||
* source `config.json`;
|
||||
* `app_config.example.json`;
|
||||
* license 或 device state 从构建机器复制过来; 或
|
||||
* 生成的产品配置中间件。
|
||||
|
||||
该包必须包含验证服务器合约所需的二进制文件和公共资源。父目标 `package_installer` 拥有这些安装规则。
|
||||
|
||||
## 必要的篡改检查
|
||||
|
||||
发布验证必须证明:
|
||||
|
||||
1. 添加或更改侧边车 JSON 不会改变嵌入式应用程序 ID、频道、端点、可执行文件名称或票证行为。
|
||||
2. 更改本地状态无法伪造有效的服务器签名或获取发布权限。
|
||||
3. 更改受保护的二进制文件或库会被清单验证检测到。
|
||||
4. 重放或编辑启动票证将被拒绝。
|
||||
5. 用较旧的已签名序列替换策略或清单会被反回滚状态拒绝。
|
||||
6. 候选启动失败会导致回滚,而不是版本状态的前进。
|
||||
|
||||
## 操作指南
|
||||
|
||||
* 请将特定版本的 `config/config.json` 保留在版本控制之外,并通过受控的构建环境提供。
|
||||
* 限制对发布构建输入的访问,但不要将客户端侧的值描述为不可提取的秘密。
|
||||
* 通过明确的发布流程轮换服务器签名密钥和客户端令牌。
|
||||
* 在原生后端和权限边界处诊断状态写入失败。不要使安装目录广泛可写。
|
||||
* 将更改的端点、频道、可执行文件名称或启动票据密钥视为新的二进制配置,需要重新构建和包验证。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 本地化维护
|
||||
|
||||
## 政策
|
||||
|
||||
英文是生产代码的源语言。用户可见的中文文本只能出现在本地化资源中。生产代码中的 C、C++、头文件和 CMake 文件不得在消息、注释、目标标签或诊断信息中包含汉字。
|
||||
|
||||
协议字段、JSON 键、命令行开关、日志标识符、错误代码和文件格式标记是稳定的接口,不应进行翻译。
|
||||
|
||||
## Qt 应用程序
|
||||
|
||||
`Launcher`、`Updater` 以及 `MainApp` 集成示例使用 Qt 翻译。
|
||||
|
||||
* 在 `QObject` 子类中使用 `tr()`,当该类提供预期的翻译上下文时。
|
||||
* 在自由函数、静态辅助函数和入口点代码中使用 `QCoreApplication::translate("StableContext", "English source")`。
|
||||
* 保持上下文名称稳定。重命名上下文会使现有的翻译条目失效。
|
||||
* 使用英文源文本作为回退语言。
|
||||
* 保持简体中文翻译目录作为已提交的 `.ts` 源文件。
|
||||
* 通过 CMake 将 `.ts` 编译为 `.qm`,并将编译后的目录嵌入到 qrc 中。
|
||||
* 安装不可松动、用户可替换的翻译文件,当构建合同需要嵌入式 UI 资源时。
|
||||
* 仅在 `Common/TranslationHelper` 中保留 qrc 目录路径。应用程序入口点不得构造其他资源路径或加载第二个翻译器。
|
||||
* 在构建 UI 对象或格式化启动错误之前初始化 `TranslationHelper`。它仅在匹配的语言环境时安装嵌入式目录;其他语言环境使用英文源文本。
|
||||
* `TranslationHelper` 还会从部署的运行时 `translations/` 目录加载 Qt 的匹配基础目录,以便标准对话框按钮使用相同语言环境。这是独立的 Qt 运行时目录,不是替代应用程序目录路径。
|
||||
|
||||
不要仅仅为了满足扫描而将字符串放在翻译调用中。每个用户可见的消息都需要在目录中有一个条目,并且需要经过审核的翻译。
|
||||
|
||||
## 引导启动
|
||||
|
||||
`Bootstrap` 仍然独立于 Qt。在 Windows 上,可本地化的 UI 文本应放在 Win32 `STRINGTABLE` 资源中,并通过资源标识符加载。英语是默认的资源语言。
|
||||
|
||||
其他支持的平台必须使用等效的平台资源机制或经过审核的资源表。不要重新引入源语言条件语句或硬编码的本地化字面量。
|
||||
|
||||
机器可读的诊断信息可以保持为稳定的英文文本。任何在对话框中显示的文本都是用户可见的,必须使用资源表。
|
||||
|
||||
## 添加或修改文本
|
||||
|
||||
1. 编写简洁的英文源字符串。
|
||||
2. 选择一个现有的稳定上下文或添加一个名称明确的上下文。
|
||||
3. 在每次翻译中保留占位符,如 `%1`、`%2`、换行符和标记。
|
||||
4. 通过 CMake 管理的提取步骤更新翻译目录。
|
||||
5. 翻译并审阅已更改的条目。
|
||||
6. 构建嵌入式翻译资源。
|
||||
7. 启用英文备用和简体中文界面路径。
|
||||
8. 运行无汉字的源语检查。
|
||||
|
||||
避免通过连接翻译片段来构建句子。词序和复数规则因语言而异。翻译完整的信息,并通过占位符传递可变内容。
|
||||
|
||||
## 错误来源
|
||||
|
||||
错误跨越多个边界,需要不同的处理方式:
|
||||
|
||||
| 源文本 | 处理方式 |
|
||||
| --- | --- |
|
||||
| 本地用户界面决策 | 翻译完整的用户可见消息。 |
|
||||
| 本地技术诊断 | 保持稳定的英文细节,并将其封装在翻译后的用户可见摘要中。 |
|
||||
| 服务器错误代码 | 将稳定的代码映射到本地翻译后的消息;不要将协议 JSON 作为主要用户界面显示。 |
|
||||
| 操作系统错误 | 保留原生细节以供诊断,并提供一个翻译后的行动导向摘要。 |
|
||||
| 仅记录事件 | 除非也展示给用户,否则保持英文稳定。 |
|
||||
|
||||
不要将本地化文本作为错误代码或事务状态发送回服务器。
|
||||
|
||||
在迁移过程中,旧的服务器响应可能仍需要匹配翻译后的短语。请重用 `TranslationHelper` 已拥有的目录;不要为分类加载第二个目录。当该 API 可用时,用稳定的服务器错误代码替换文本匹配。
|
||||
|
||||
## 目录和资源审核
|
||||
|
||||
在合并 i18n 变更之前,请确认:
|
||||
|
||||
* 每个修改过的用户可见源字符串都出现在目录中;
|
||||
* 主启动/更新流程中没有未完成的条目;
|
||||
* 占位符在源字符串和翻译之间完全匹配;
|
||||
* 加速器标记和富文本标记保持有效;
|
||||
* the `.qm` 输出被嵌入到每个使用它的应用程序中;
|
||||
* 启动、提升、更新、回滚和致命错误对话框都已涵盖;
|
||||
* 不可用的语言环境会回退到英文,且不显示空白文本;
|
||||
* 生成的 `.qm` 路径不依赖于开发者的开发工作站。
|
||||
|
||||
## 源代码扫描范围
|
||||
|
||||
自动的源代码检查应包括生产代码:
|
||||
|
||||
* `*.c`, `*.cc`, `*.cpp`,以及 `*.h`;
|
||||
* `CMakeLists.txt` 和 `*.cmake`;和
|
||||
* 平台源资源,除已批准的本地化表之外。
|
||||
|
||||
它应排除翻译目录、已批准的 `STRINGTABLE` 资源、文档、第三方代码、生成的文件和构建输出。排除项必须明确,以便新添加的生产目录默认被检查。
|
||||
|
||||
## 运行时验证
|
||||
|
||||
自动目录检查是必要的但不够的。发布验收必须至少检查以下内容:
|
||||
|
||||
* 首次注册和凭证错误;
|
||||
* 离线策略和强制更新信息;
|
||||
* 更新下载、验证和回滚失败;
|
||||
* 提升失败或取消;
|
||||
* 直接启动拒绝;以及
|
||||
* 引导程序替换失败。
|
||||
|
||||
请确认文本与实际对话框相符,并且切换操作系统区域设置不会改变协议行为或配置选择。
|
||||
@@ -0,0 +1,129 @@
|
||||
# Legacy 配置迁移
|
||||
|
||||
## 范围
|
||||
|
||||
较旧的 update-client 构建版本会读取并重写可执行文件旁边的 product 数据。在升级安装中,可能存在两种格式:
|
||||
|
||||
* `config/app_config.json`; 和
|
||||
* client.ini
|
||||
|
||||
新版本使用嵌入式产品配置加上本地机器范围的状态存储。旧文件仅作为迁移输入,它们永远不会覆盖嵌入式应用程序标识、服务器端点、通道、可执行策略或票据配置。
|
||||
|
||||
此迁移与配置时的 `config/config.json` 无关。后者是受控的源代码树或构建流水线输入,从不会从安装中读取。
|
||||
|
||||
## 迁移优先级
|
||||
|
||||
迁移仅在原生状态存储尚未为此产品安装完成迁移时运行。
|
||||
|
||||
1. 现有有效的原生状态胜利,且不读取遗留文件。
|
||||
2. 否则,导入允许的可变值 `config/app_config.json` 在存在且有效时。
|
||||
3. 从 `client.ini` 中填充仍缺失的允许值(当存在时)。
|
||||
4. 验证导入的值并将它们原子性地持久化到原生存储中。
|
||||
5. 仅在状态写入成功后记录迁移完成。
|
||||
|
||||
迁移必须是幂等的。对旧文件的后续编辑不应改变已迁移的安装。
|
||||
|
||||
## 允许的值
|
||||
|
||||
只能导入可变的安装状态:
|
||||
|
||||
| 旧值 | 新所有者 | 迁移规则 |
|
||||
| --- | --- | --- |
|
||||
| current\_version | 原生安装版本状态 | 从未签名的旧文件中导入;从嵌入的构建状态初始化,并且仅通过经过验证的更新事务进行升级。 |
|
||||
| license\_key | Native enrollment state | Import as enrollment input; the server still validates it. |
|
||||
| device\_id | 原生设备状态 | 当有有效签名设备凭证时,优先使用其值。 |
|
||||
| installation\_id | 原生安装状态 | 仅在语法有效时导入;否则通过当前状态服务生成。 |
|
||||
| 单调策略/更新标记 | 原生状态或签名缓存 | 仅通过专用验证导入;永远不要信任未签名的较低序列。 |
|
||||
|
||||
迁移时不得将这些遗留值作为运行时覆盖导入:
|
||||
|
||||
* `app_id` 或 `app_name`;
|
||||
* `channel` 或 `client_protocol`;
|
||||
* `api_base_url` 或 `client_token`;
|
||||
* launch\_token;
|
||||
* 可执行文件名或 `install_root`;
|
||||
* 临时目录布局;
|
||||
* 平台或架构;或
|
||||
* 请求和健康检查策略。
|
||||
|
||||
这些值只能来自构建期间选择的嵌入式产品配置。
|
||||
|
||||
## 旧版 INI 映射参考
|
||||
|
||||
旧的 INI 格式将值分组如下。此表仅用于支持有限的迁移阅读器和法医诊断。
|
||||
|
||||
| Section | Keys previously used |
|
||||
| --- | --- |
|
||||
| App | app\_id, app\_name, channel, current\_version, client\_protocol, launch\_token |
|
||||
| 许可协议 | license\_key |
|
||||
| 服务器 | api\_base\_url, client\_token |
|
||||
| 更新 | request\_timeout\_ms, temp\_folder, device\_id |
|
||||
| 设备 | installation\_id |
|
||||
| 运行时 | install\_root, main\_executable, launcher\_executable, updater\_executable, bootstrap\_executable, health\_check\_timeout\_ms |
|
||||
|
||||
只有上述可变子集可能离开此解析器。该表不会将其他键识别为支持的设置。
|
||||
|
||||
## 验证与失败
|
||||
|
||||
* 解析旧版 JSON 和 INI 文件而不更改文件内容。
|
||||
* 在编写本地状态之前,应用严格的长度和格式检查。
|
||||
* 不要将未签名的策略序列向后复制。
|
||||
* 不要将遗留路径接受为可执行文件或更新根。
|
||||
* 将所有导入的状态原子地写入,然后设置迁移完成标记。
|
||||
* 如果持久化失败,请报告一个可操作的错误并保留迁移未完成状态,以便在权限或存储问题解决后可以重试。
|
||||
* 不要在每次启动时都回退到读取旧文件。
|
||||
|
||||
格式错误的旧文件不得阻止干净安装与当前产品配置的注册。保留足够的诊断信息以识别被拒绝的来源,但不要显示许可证材料或客户端令牌。
|
||||
|
||||
## 已签名的旧文件
|
||||
|
||||
旧的安装可能还包含:
|
||||
|
||||
* `config/client_identity.dat`;
|
||||
* `config/version_policy.dat`;
|
||||
* `config/local_state.json`; 和
|
||||
* update/manifest\_cache/
|
||||
|
||||
这些不是产品配置。只有在当前签名、身份、通道、版本、过期时间和防回滚检查通过后,才能重复使用已签名的设备凭证、策略或清单。未签名的本地状态只是一个迁移提示,不能降低已记录的序列号或延长离线有效性。
|
||||
|
||||
迁移实现应将接受的状态移动到当前批准的存储或缓存位置。它不得继续将可执行目录视为可写运行时数据库。
|
||||
|
||||
## 打包规则
|
||||
|
||||
父级 `package_installer` 输出中不得包含遗留配置或状态。特别是,不要打包:
|
||||
|
||||
* app\_config.json;
|
||||
* `client.ini`;
|
||||
* 开发机器的设备凭证;
|
||||
* 本地策略状态;
|
||||
* 交易状态;或
|
||||
* 更新下载和备份目录。
|
||||
|
||||
存在迁移代码用于从旧版本升级,而不是为了维持旧的软件包结构。
|
||||
|
||||
## 操作规程
|
||||
|
||||
对于现有安装:
|
||||
|
||||
1. 通过受支持的安装程序/更新路径安装已签名的新包。
|
||||
2. 通过 `Launcher` 启动,并使用原生机器状态所需的平台权限。
|
||||
3. 确认设备注册、策略验证和已安装版本状态。
|
||||
4. 确认第二次启动成功,无需查阅已更改的旧文件。
|
||||
5. 确认直接应用启动仍被阻止。
|
||||
|
||||
迁移成功后,旧文件可以归档用于事件分析,或通过批准的清理路径删除。它们的持续存在不应影响运行时策略。
|
||||
|
||||
## 迁移测试
|
||||
|
||||
覆盖至少:
|
||||
|
||||
* 无遗留文件的干净安装;
|
||||
* 仅 JSON 迁移;
|
||||
* 仅 INI 迁移;
|
||||
* JSON 优先,INI 作为缺失可变值的后备;
|
||||
* 现有原生状态带有恶意的遗留覆盖;
|
||||
* 格式错误的 JSON 和格式错误的 INI 值;
|
||||
* 无效或过期的签名遗留凭证;
|
||||
* 原生状态写入失败后成功重试;
|
||||
* 迁移完成后重复启动;以及
|
||||
* 迁移后的侧车配置被篡改。
|
||||
@@ -1,74 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$SdkRoot,
|
||||
|
||||
[string]$ReleaseDir = (Get-Location).Path,
|
||||
|
||||
[switch]$OverwriteConfig,
|
||||
|
||||
[switch]$IncludeQtRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$sdk = (Resolve-Path $SdkRoot).Path
|
||||
$release = (Resolve-Path $ReleaseDir).Path
|
||||
|
||||
$binDir = Join-Path $sdk "bin"
|
||||
$configDir = Join-Path $sdk "config"
|
||||
$appConfig = Join-Path $configDir "app_config.json"
|
||||
$publicKey = Join-Path $configDir "manifest_public_key.pem"
|
||||
|
||||
foreach ($path in @($binDir, $appConfig, $publicKey)) {
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "SDK file is missing: $path"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsQtRuntimeItem {
|
||||
param([System.IO.FileSystemInfo]$Item)
|
||||
|
||||
if ($IncludeQtRuntime) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($Item.PSIsContainer) {
|
||||
return $Item.Name -in @(
|
||||
"bearer",
|
||||
"iconengines",
|
||||
"imageformats",
|
||||
"platforms",
|
||||
"styles",
|
||||
"translations"
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
$Item.Name -match '^Qt5.*\.dll$' -or
|
||||
$Item.Name -in @(
|
||||
"libEGL.dll",
|
||||
"libGLESv2.dll",
|
||||
"opengl32sw.dll",
|
||||
"d3dcompiler_47.dll"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Get-ChildItem $binDir -Force | Where-Object {
|
||||
-not (Test-IsQtRuntimeItem $_)
|
||||
} | Copy-Item -Destination $release -Recurse -Force
|
||||
|
||||
$targetConfigDir = Join-Path $release "config"
|
||||
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$targetAppConfig = Join-Path $targetConfigDir "app_config.json"
|
||||
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
|
||||
Copy-Item $appConfig $targetAppConfig -Force
|
||||
} else {
|
||||
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
|
||||
}
|
||||
|
||||
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force
|
||||
|
||||
Write-Host "SDK files installed to: $release"
|
||||
Write-Host "Next: edit config/app_config.json, then start Launcher.exe."
|
||||
@@ -1,138 +0,0 @@
|
||||
param(
|
||||
[string]$SourceDir = "$PSScriptRoot/out/bin",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigFile,
|
||||
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClient",
|
||||
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$source = (Resolve-Path $SourceDir).Path
|
||||
$config = (Resolve-Path $ConfigFile).Path
|
||||
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
function Normalize-RelativePath([string]$PathValue) {
|
||||
return ($PathValue -replace '\\', '/').Trim('/')
|
||||
}
|
||||
|
||||
function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) {
|
||||
$full = (Resolve-Path $FullPath).Path
|
||||
if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/'))
|
||||
}
|
||||
return Normalize-RelativePath $Fallback
|
||||
}
|
||||
|
||||
$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json"
|
||||
$configRelativeParent = Split-Path $configRelativePath -Parent
|
||||
$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent)
|
||||
if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" }
|
||||
|
||||
function Join-RelativePath([string]$Base, [string]$Child) {
|
||||
$baseNorm = Normalize-RelativePath $Base
|
||||
$childNorm = Normalize-RelativePath $Child
|
||||
if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm }
|
||||
if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm }
|
||||
return "$baseNorm/$childNorm"
|
||||
}
|
||||
|
||||
$requiredFields = @(
|
||||
"app_id", "channel", "api_base_url", "current_version",
|
||||
"client_token", "launch_token", "license_key",
|
||||
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
|
||||
)
|
||||
foreach ($field in $requiredFields) {
|
||||
if (-not $settings.$field) {
|
||||
throw "Config file is missing required field: $field"
|
||||
}
|
||||
}
|
||||
|
||||
$mainExecutable = Normalize-RelativePath $settings.main_executable
|
||||
$launcherExecutable = Normalize-RelativePath $settings.launcher_executable
|
||||
$updaterExecutable = Normalize-RelativePath $settings.updater_executable
|
||||
$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable
|
||||
|
||||
$requiredFiles = @(
|
||||
(Join-RelativePath $runtimeDirRelative $mainExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $launcherExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $updaterExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $bootstrapExecutable)
|
||||
)
|
||||
foreach ($name in $requiredFiles) {
|
||||
$nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar
|
||||
if (-not (Test-Path (Join-Path $source $nativeName))) {
|
||||
throw "Source directory is missing required file: $name"
|
||||
}
|
||||
}
|
||||
|
||||
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
|
||||
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
|
||||
$_.Extension -in @('.pdb', '.ilk')
|
||||
}
|
||||
if ($debugArtifacts) {
|
||||
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
|
||||
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
||||
$mainLeafName = Split-Path $mainExecutable -Leaf
|
||||
$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object {
|
||||
$relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/'))
|
||||
$relative -ne $expectedMainPath
|
||||
} | Select-Object -First 1
|
||||
if ($duplicateMain) {
|
||||
throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)"
|
||||
}
|
||||
|
||||
$manifestName = "manifest_$($settings.current_version).json"
|
||||
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
|
||||
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if (-not (Test-Path $sourceManifest)) {
|
||||
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
|
||||
if (Test-Path $legacySourceManifest) {
|
||||
$sourceManifest = $legacySourceManifest
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path $sourceManifest)) {
|
||||
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
|
||||
}
|
||||
|
||||
if (Test-Path $OutputDir) {
|
||||
Remove-Item $OutputDir -Recurse -Force
|
||||
}
|
||||
New-Item $OutputDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
Get-ChildItem $source -Force | Where-Object {
|
||||
$_.Name -notin @("update", "update_temp")
|
||||
} | Copy-Item -Destination $OutputDir -Recurse -Force
|
||||
|
||||
$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if (Test-Path $outputRuntimeUpdateDir) {
|
||||
Remove-Item $outputRuntimeUpdateDir -Recurse -Force
|
||||
}
|
||||
$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if (Test-Path $outputRuntimeTempDir) {
|
||||
Remove-Item $outputRuntimeTempDir -Recurse -Force
|
||||
}
|
||||
|
||||
$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
$outputConfigDir = Split-Path $outputConfigPath -Parent
|
||||
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
|
||||
Copy-Item $config $outputConfigPath -Force
|
||||
|
||||
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
|
||||
$runtimeFile = Join-Path $outputConfigDir $_
|
||||
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force }
|
||||
}
|
||||
|
||||
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
New-Item $manifestDir -ItemType Directory -Force | Out-Null
|
||||
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
||||
|
||||
$zipParent = Split-Path $ZipFile -Parent
|
||||
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
||||
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
|
||||
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
|
||||
|
||||
Write-Host "Package directory: $OutputDir"
|
||||
Write-Host "ZIP file: $ZipFile"
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
param(
|
||||
[string]$SourceDir = "$PSScriptRoot/out/bin",
|
||||
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK",
|
||||
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
|
||||
[string]$SdkVersion = "0.1.0",
|
||||
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
|
||||
[switch]$IncludeDemoMainApp,
|
||||
[switch]$IncludeQtRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$source = (Resolve-Path $SourceDir).Path
|
||||
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
|
||||
|
||||
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
|
||||
foreach ($name in $requiredFiles) {
|
||||
$path = Join-Path $source $name
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "SDK source directory is missing required file: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$publicKeyCandidates = @(
|
||||
(Join-Path $source "config/manifest_public_key.pem"),
|
||||
(Join-Path $source "manifest_public_key.pem"),
|
||||
(Join-Path $PSScriptRoot "config/manifest_public_key.pem")
|
||||
)
|
||||
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $publicKey) {
|
||||
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key."
|
||||
}
|
||||
|
||||
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
|
||||
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
|
||||
$_.Extension -in @('.pdb', '.ilk')
|
||||
}
|
||||
if ($debugArtifacts) {
|
||||
throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
|
||||
if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force }
|
||||
New-Item $OutputDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$binDir = Join-Path $OutputDir "bin"
|
||||
$configDir = Join-Path $OutputDir "config"
|
||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
|
||||
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
|
||||
|
||||
if (-not $IncludeQtRuntime) {
|
||||
$excludedTopLevel += @(
|
||||
"bearer",
|
||||
"iconengines",
|
||||
"imageformats",
|
||||
"platforms",
|
||||
"styles",
|
||||
"translations"
|
||||
)
|
||||
}
|
||||
|
||||
function Test-IsQtRuntimeFile {
|
||||
param([System.IO.FileSystemInfo]$Item)
|
||||
|
||||
if ($IncludeQtRuntime -or $Item.PSIsContainer) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return (
|
||||
$Item.Name -match '^Qt5.*\.dll$' -or
|
||||
$Item.Name -in @(
|
||||
"libEGL.dll",
|
||||
"libGLESv2.dll",
|
||||
"opengl32sw.dll",
|
||||
"d3dcompiler_47.dll"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Get-ChildItem $source -Force | Where-Object {
|
||||
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
|
||||
} | Copy-Item -Destination $binDir -Recurse -Force
|
||||
|
||||
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
||||
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
|
||||
|
||||
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object {
|
||||
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*"
|
||||
} | Sort-Object Name | Select-Object -First 1
|
||||
if (-not $wordGuideSource) {
|
||||
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory."
|
||||
}
|
||||
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
|
||||
|
||||
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force
|
||||
|
||||
@{
|
||||
sdk_version = $SdkVersion
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
sdk_type = "external-updater-runtime"
|
||||
required_entry = "Launcher.exe"
|
||||
contains_demo_main_app = [bool]$IncludeDemoMainApp
|
||||
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
|
||||
|
||||
$zipParent = Split-Path $ZipFile -Parent
|
||||
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
||||
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
|
||||
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
|
||||
|
||||
Write-Host "SDK directory: $OutputDir"
|
||||
Write-Host "SDK zip: $ZipFile"
|
||||
Write-Host "SDK version: $SdkVersion"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="zh_CN" sourcelanguage="en_US">
|
||||
<context>
|
||||
<name>Launcher</name>
|
||||
<message>
|
||||
<source>Checking for software updates...</source>
|
||||
<translation>正在检查软件更新...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Marsco Launcher</source>
|
||||
<translation>Marsco 软件启动器</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>application</source>
|
||||
<translation>软件</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enter the license for %1:</source>
|
||||
<translation>请输入%1授权 License:</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>%1
|
||||
|
||||
Please re-enter the license for %2:</source>
|
||||
<translation>%1
|
||||
|
||||
请重新输入%2授权 License:</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enter License</source>
|
||||
<translation>输入 License</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Required</source>
|
||||
<translation>需要 License</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A license provided by an administrator is required for the first launch.</source>
|
||||
<translation>首次启动需要输入管理员提供的 License。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Cannot Be Empty</source>
|
||||
<translation>License 不能为空</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Paste the license created by an administrator.</source>
|
||||
<translation>请粘贴管理员创建的 License。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Failed to Save License</source>
|
||||
<translation>保存 License 失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save system state at %1:
|
||||
%2</source>
|
||||
<translation>无法在 %1 保存系统状态:
|
||||
%2</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Verifying license...</source>
|
||||
<translation>正在验证 License...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Device Authentication Failed</source>
|
||||
<translation>设备身份验证失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current license cannot be used: %1</source>
|
||||
<translation>当前 License 无法使用:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The device or license authorization is invalid.</source>
|
||||
<translation>设备或 License 授权无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The server rejected the current license: %1</source>
|
||||
<translation>当前 License 被服务端拒绝:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Saved</source>
|
||||
<translation>License 已保存</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Restart Launcher to complete device authorization and check for updates.</source>
|
||||
<translation>请重新启动 Launcher 完成设备授权和更新检查。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Authorization Denied</source>
|
||||
<translation>授权被拒绝</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Select Offline Update Package</source>
|
||||
<translation>选择离线更新包</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Marsco Offline Update Package (*.upd)</source>
|
||||
<translation>Marsco 离线更新包 (*.upd)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Update</source>
|
||||
<translation>离线更新</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No offline update package was selected.</source>
|
||||
<translation>未选择离线更新包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the local run policy...</source>
|
||||
<translation>正在验证本地运行策略...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Unable to Start</source>
|
||||
<translation>无法启动</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local version policy is invalid: %1</source>
|
||||
<translation>本地版本策略无效:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local version policy has expired. Connect to the network or contact an administrator.</source>
|
||||
<translation>本地版本策略已过期,请连接网络或联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Current Version Cannot Run</source>
|
||||
<translation>当前版本不可运行</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version %1 has been disabled by an administrator.</source>
|
||||
<translation>当前版本 %1 已被管理员停用。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot read the local state: %1</source>
|
||||
<translation>无法读取本地状态:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Security Check Failed</source>
|
||||
<translation>安全检查失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A version policy sequence rollback was detected, so startup was blocked.</source>
|
||||
<translation>检测到版本策略序列回退,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A possible system clock rollback was detected, so startup was blocked.</source>
|
||||
<translation>检测到系统时间可能被回拨,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version Rollback</source>
|
||||
<translation>版本回退</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Required</source>
|
||||
<translation>必须更新</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>New Version Available</source>
|
||||
<translation>发现新版本</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>An administrator selected version %1 as the rollback target. Downgrade now?</source>
|
||||
<translation>管理员选择版本 %1 作为回退目标,是否现在降级?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version %1 is available. Update now?</source>
|
||||
<translation>发现新版本 %1,是否现在更新?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>
|
||||
Target version: %1</source>
|
||||
<translation>
|
||||
目标版本:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Failed</source>
|
||||
<translation>启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot start the main application: %1</source>
|
||||
<translation>无法启动主程序:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Updater Startup Failed</source>
|
||||
<translation>更新器启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot start the updater: %1</source>
|
||||
<translation>无法启动更新器:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Server Unavailable</source>
|
||||
<translation>服务器不可用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update server is currently unreachable. Import an offline update package?</source>
|
||||
<translation>当前无法连接更新服务器。是否导入离线更新包?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No offline update package was selected, or the updater could not be started.</source>
|
||||
<translation>未选择离线更新包或无法启动更新器。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Network Unavailable</source>
|
||||
<translation>网络不可用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update server is unreachable and the current policy does not allow offline startup.</source>
|
||||
<translation>无法连接更新服务器,且当前策略不允许离线启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current version is up to date. Starting...</source>
|
||||
<translation>当前已是最新版本,正在启动...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline mode is active. Starting...</source>
|
||||
<translation>当前处于离线模式,正在启动...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Integrity Check Failed</source>
|
||||
<translation>完整性检查失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Application files failed signed manifest verification:
|
||||
%1</source>
|
||||
<translation>应用文件未通过签名清单验证:
|
||||
%1</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>LauncherErrorMarker</name>
|
||||
<message>
|
||||
<source>authorization</source>
|
||||
<translation>授权</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>expired</source>
|
||||
<translation>过期</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>device limit reached</source>
|
||||
<translation>设备数量已达到上限</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>bound to another license</source>
|
||||
<translation>已绑定其他授权</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MainApp</name>
|
||||
<message>
|
||||
<source>Startup Restriction</source>
|
||||
<translation>启动受限</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Invalid or missing one-time launch ticket: %1
|
||||
Please use %2.</source>
|
||||
<translation>一次性启动票据无效或缺失:%1
|
||||
请通过 %2 启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Error</source>
|
||||
<translation>授权错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Local license invalid: %1</source>
|
||||
<translation>本地授权无效:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Policy Error</source>
|
||||
<translation>策略错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Local policy invalid: %1</source>
|
||||
<translation>本地策略无效:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Policy expired. The application cannot start.</source>
|
||||
<translation>策略已过期,应用程序无法启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>State Error</source>
|
||||
<translation>状态错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot load local state: %1</source>
|
||||
<translation>无法加载本地状态:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A policy sequence rollback was detected, so startup was blocked.</source>
|
||||
<translation>检测到策略序列回退,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The system clock appears to have moved backward, so startup was blocked.</source>
|
||||
<translation>检测到系统时间可能被回拨,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Integrity Check Failed</source>
|
||||
<translation>完整性检查失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Application files failed signed manifest verification:
|
||||
%1</source>
|
||||
<translation>应用程序文件未通过签名 Manifest 校验:
|
||||
%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save local state: %1</source>
|
||||
<translation>无法保存本地状态:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Error</source>
|
||||
<translation>启动错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot write the update health confirmation file.</source>
|
||||
<translation>无法写入更新健康确认文件。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MainWindow</name>
|
||||
<message>
|
||||
<source>missing</source>
|
||||
<translation>缺失</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>unreadable</source>
|
||||
<translation>无法读取</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Marsco Demo MainApp</source>
|
||||
<translation>Marsco 主程序演示</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>unknown</source>
|
||||
<translation>未知</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy missing</source>
|
||||
<translation>策略缺失</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy invalid</source>
|
||||
<translation>策略无效</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>version disabled</source>
|
||||
<translation>版本已停用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy expired</source>
|
||||
<translation>策略已过期</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy ok</source>
|
||||
<translation>策略有效</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Software Running Successfully
|
||||
Version: %1
|
||||
DLL Version: %2
|
||||
Policy: %3</source>
|
||||
<translation>软件运行成功
|
||||
版本:%1
|
||||
DLL 版本:%2
|
||||
策略:%3</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed policy does not allow this installed version to run.</source>
|
||||
<translation>签名策略不允许运行当前安装版本。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>Updater</name>
|
||||
<message>
|
||||
<source>Invalid Offline Package</source>
|
||||
<translation>离线包无效</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Invalid Updater Arguments</source>
|
||||
<translation>更新器参数错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The updater requires online update arguments or an offline update package.</source>
|
||||
<translation>更新器缺少在线更新参数或离线更新包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Package Not Applicable</source>
|
||||
<translation>离线包不适用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update package application or channel does not match this installation.</source>
|
||||
<translation>更新包的应用或渠道与本机配置不一致。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Update Denied</source>
|
||||
<translation>离线更新被拒绝</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local signed policy has expired, prohibits offline updates, or does not allow the target version.</source>
|
||||
<translation>本地签名策略已过期、禁止离线更新或不允许目标版本。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Downgrade Prohibited</source>
|
||||
<translation>禁止降级</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current signed policy does not allow installing an older offline package.</source>
|
||||
<translation>当前签名策略不允许安装较低版本的离线包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Recovery Failed</source>
|
||||
<translation>更新恢复失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The previous update did not finish, and the old version could not be restored: %1
|
||||
Do not continue running the software. Contact an administrator.</source>
|
||||
<translation>检测到上次更新未完成,但无法恢复旧版本:%1
|
||||
请不要继续运行软件,并联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The old files were restored, but the version state could not be restored. Check access to the system state store.</source>
|
||||
<translation>旧文件已经恢复,但无法恢复版本状态,请检查系统状态存储的访问权限。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Preparing the update...</source>
|
||||
<translation>正在准备更新...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Updating to %1</source>
|
||||
<translation>正在更新到 %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Preparing the download...</source>
|
||||
<translation>正在准备下载...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Downloading: %1</source>
|
||||
<translation>正在下载:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Handing rollback over to Bootstrap...</source>
|
||||
<translation>正在将回滚工作移交给 Bootstrap...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>
|
||||
|
||||
Bootstrap could not be started to perform the rollback. Do not continue running the software. Contact an administrator.</source>
|
||||
<translation>
|
||||
|
||||
无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Transaction Resume Failed</source>
|
||||
<translation>事务续办失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot read the Bootstrap update transaction: %1</source>
|
||||
<translation>无法读取 Bootstrap 更新事务:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Rolled Back</source>
|
||||
<translation>更新已回滚</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version could not be installed or started. The old version was restored and started automatically.</source>
|
||||
<translation>新版本安装或启动失败,已自动恢复并启动旧版本。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The old files were restored, but the old version number could not be written back. Check permissions on the system state store.</source>
|
||||
<translation>旧文件已经恢复,但旧版本号写回失败,请检查系统状态存储权限。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Automatic Rollback Failed</source>
|
||||
<translation>自动回滚失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator.</source>
|
||||
<translation>Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Preparation Failed</source>
|
||||
<translation>更新准备失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot create the update transaction directory or save its state.</source>
|
||||
<translation>无法创建更新事务目录或保存事务状态。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the offline transaction marker.</source>
|
||||
<translation>无法保存离线事务标记。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Fetching and validating the version manifest...</source>
|
||||
<translation>正在获取并验证版本清单...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the offline update package...</source>
|
||||
<translation>正在验证离线更新包...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Manifest Cache Failed</source>
|
||||
<translation>清单缓存失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed manifest cache could not be read after Bootstrap installation.</source>
|
||||
<translation>Bootstrap 安装后无法读取签名 Manifest 缓存。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Security Validation Failed</source>
|
||||
<translation>安全验证失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The version manifest signature could not be revalidated after Bootstrap installation.</source>
|
||||
<translation>Bootstrap 安装后无法重新验证版本清单签名。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The version manifest signature is invalid. The update has stopped. Contact an administrator.</source>
|
||||
<translation>版本清单签名无效,更新已停止。请联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the new version manifest cache.</source>
|
||||
<translation>无法保存新版本 Manifest 缓存。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the new version manifest cache. The update has stopped.</source>
|
||||
<translation>无法保存新版本 Manifest 缓存,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Fetching secure download URLs...</source>
|
||||
<translation>正在获取安全下载地址...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Preparing offline package files...</source>
|
||||
<translation>正在准备离线包文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No Update Files</source>
|
||||
<translation>没有可更新文件</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The server returned no version files. The update has stopped.</source>
|
||||
<translation>服务器没有返回任何版本文件,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Insufficient Disk Space</source>
|
||||
<translation>磁盘空间不足</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update requires at least %1 of free space, but the installation drive has only %2 available.
|
||||
The required space includes downloaded files, the old-version backup, and a safety margin.</source>
|
||||
<translation>更新至少需要 %1 可用空间,安装盘当前仅剩 %2。
|
||||
所需空间已包含下载文件、旧版本备份和安全余量。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Downloading and validating %1 version files...</source>
|
||||
<translation>正在下载并校验 %1 个版本文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Extracting and validating %1 offline files...</source>
|
||||
<translation>正在提取并校验 %1 个离线文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Package Extraction Failed</source>
|
||||
<translation>离线包提取失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Download Failed</source>
|
||||
<translation>下载失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Some files could not be downloaded or failed SHA-256 validation. Check the network and try again.</source>
|
||||
<translation>部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the complete version files...</source>
|
||||
<translation>正在校验完整版本文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>File Validation Failed</source>
|
||||
<translation>文件校验失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The staged files do not match the version manifest. The update has stopped.</source>
|
||||
<translation>暂存文件与版本清单不一致,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bootstrap Cannot Update Itself</source>
|
||||
<translation>Bootstrap 无法自更新</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>This version contains a new %1. Upgrade this component with the installer, then republish the application version.</source>
|
||||
<translation>本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Transaction Recording Failed</source>
|
||||
<translation>事务记录失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the validated-file or pending-deletion lists. The update has stopped.</source>
|
||||
<translation>无法保存已校验或待删除文件列表,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Closing the main application...</source>
|
||||
<translation>正在关闭主程序...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot Close Main Application</source>
|
||||
<translation>无法关闭主程序</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>%1 is still running. Close it manually and try again.</source>
|
||||
<translation>%1 仍在运行,请手动关闭后重试。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backing up %1 files to be changed, including %2 deletions...</source>
|
||||
<translation>正在备份 %1 个待变更文件(其中删除 %2 个)...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup Failed</source>
|
||||
<translation>备份失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot back up the current version files. The new version has not been installed. Check disk space and directory permissions.</source>
|
||||
<translation>无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Handoff Preparation Failed</source>
|
||||
<translation>接管准备失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot create the Bootstrap file plan.</source>
|
||||
<translation>无法创建 Bootstrap 文件计划。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot write the Bootstrap copy plan.</source>
|
||||
<translation>无法写入 Bootstrap 复制计划。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot write the Bootstrap deletion plan.</source>
|
||||
<translation>无法写入 Bootstrap 删除计划。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot commit the Bootstrap file plan or transaction state.</source>
|
||||
<translation>无法提交 Bootstrap 文件计划或事务状态。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Handing installation over to Bootstrap...</source>
|
||||
<translation>正在将安装工作移交给 Bootstrap...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bootstrap Startup Failed</source>
|
||||
<translation>Bootstrap 启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot start the independent update handoff program: %1</source>
|
||||
<translation>无法启动独立更新接管程序:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the Bootstrap installation result...</source>
|
||||
<translation>正在校验 Bootstrap 安装结果...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Installation Validation Failed</source>
|
||||
<translation>安装校验失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version files failed validation after installation.</source>
|
||||
<translation>新版本文件安装后校验未通过。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Obsolete File Cleanup Failed</source>
|
||||
<translation>废弃文件清理失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>An obsolete file still exists: %1</source>
|
||||
<translation>废弃文件仍然存在:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Saving the new version state...</source>
|
||||
<translation>正在保存新版本状态...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>State Save Failed</source>
|
||||
<translation>状态保存失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the current version number.</source>
|
||||
<translation>无法保存当前版本号。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Starting the new version and waiting for a health confirmation...</source>
|
||||
<translation>正在启动新版本并等待健康确认...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Failed</source>
|
||||
<translation>启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>%1 could not be started.</source>
|
||||
<translation>%1 无法启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Confirmation Failed</source>
|
||||
<translation>启动确认失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version did not complete its startup health confirmation within %1 milliseconds.</source>
|
||||
<translation>新版本在 %1 毫秒内没有完成启动健康确认。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Committing the update transaction...</source>
|
||||
<translation>正在提交更新事务...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Transaction Commit Failed</source>
|
||||
<translation>事务提交失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version started, but the update transaction could not be committed.</source>
|
||||
<translation>新版本已经启动,但无法提交更新事务。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Complete</source>
|
||||
<translation>更新完成</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The software was updated successfully to %1 and passed the startup health check.</source>
|
||||
<translation>软件已成功更新到 %1,并通过启动健康检查。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Denied</source>
|
||||
<translation>更新被拒绝</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local signed policy is invalid, expired, not applicable to this installation, or does not allow the requested update.</source>
|
||||
<translation>本地签名策略无效、已过期、不适用于此安装,或不允许请求的更新。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current signed policy does not allow installing an older version.</source>
|
||||
<translation>当前签名策略不允许安装旧版本。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>UpdaterLogic</name>
|
||||
<message>
|
||||
<source>Cannot open the offline update package.</source>
|
||||
<translation>无法打开离线更新包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package format identifier is invalid.</source>
|
||||
<translation>离线包格式标识无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package header is incomplete.</source>
|
||||
<translation>离线包头不完整。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package header length is invalid.</source>
|
||||
<translation>离线包头长度无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package header contains invalid JSON.</source>
|
||||
<translation>离线包头 JSON 无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package RSA signature is invalid.</source>
|
||||
<translation>离线包 RSA 签名无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed offline package metadata is invalid.</source>
|
||||
<translation>离线包签名元数据无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The manifest digest does not match the package signature.</source>
|
||||
<translation>Manifest 摘要与包签名不一致。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline manifest is invalid.</source>
|
||||
<translation>离线 Manifest 无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The package metadata and manifest identity do not match.</source>
|
||||
<translation>包信息与 Manifest 身份不一致。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline manifest RSA signature is invalid.</source>
|
||||
<translation>离线 Manifest RSA 签名无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package contains an unsafe path or out-of-bounds data: %1</source>
|
||||
<translation>离线包包含不安全路径或越界数据:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot prepare the offline file: %1</source>
|
||||
<translation>无法准备离线文件:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot create the staged file: %1</source>
|
||||
<translation>无法创建暂存文件:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Failed to read the offline file: %1</source>
|
||||
<translation>离线文件读取失败:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline file hash validation or writing failed: %1</source>
|
||||
<translation>离线文件 Hash 校验或写入失败:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The number of files in the offline package does not match the manifest.</source>
|
||||
<translation>离线包文件数量与 Manifest 不一致。</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
Reference in New Issue
Block a user