6 Commits

Author SHA1 Message Date
Comely b736472b55 fix(integrity): exclude installer-managed metadata 2026-07-12 18:44:11 -07:00
Comely b098792001 fix(integrity): restore bootstrap runtime protection 2026-07-12 17:36:53 -07:00
Comely c5e8f34f75 fix(cmake): exclude demo app from product integration 2026-07-12 07:15:39 -07:00
Comely bec05cdbc8 fix(launcher): bootstrap release state and signed manifest 2026-07-10 02:38:04 -07:00
Comely 9d632f3323 docs(config): require publisher-supplied release license 2026-07-10 01:38:22 -07:00
Comely fe5aa5bbf0 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 删运行时编码设置。
2026-07-10 00:38:23 -07:00
71 changed files with 7166 additions and 8047 deletions
-6
View File
@@ -1,6 +0,0 @@
root = true
[*]
charset = utf-8-bom
trim_trailing_whitespace = true
insert_final_newline = true
+2 -3
View File
@@ -22,8 +22,6 @@ Desktop.ini
*.pdf *.pdf
*.doc *.doc
*.docx *.docx
private/
pdf_requirements.txt
# C/C++ generated artifacts # C/C++ generated artifacts
*.obj *.obj
@@ -51,7 +49,6 @@ CMakeUserPresets.json
Testing/ Testing/
# Third-party/business binary drops and generated packages # Third-party/business binary drops and generated packages
thirdparty/
App/ App/
dist/ dist/
*.zip *.zip
@@ -62,6 +59,8 @@ dist/
*.rar *.rar
# Local runtime state and credentials # Local runtime state and credentials
client.ini
config/config.json
config/app_config.json config/app_config.json
config/client_identity.dat config/client_identity.dat
config/local_state.json config/local_state.json
+13 -14
View File
@@ -1,20 +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 add_executable(Bootstrap ${_bootstrap_sources})
main.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
target_compile_features(Bootstrap PRIVATE cxx_std_17) target_compile_features(Bootstrap PRIVATE cxx_std_17)
target_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets) set_target_properties(Bootstrap PROPERTIES
AUTOMOC OFF
AUTOUIC OFF
AUTORCC OFF
)
if(WIN32) if(WIN32)
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) target_link_libraries(Bootstrap PRIVATE shell32)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
add_custom_command(TARGET Bootstrap POST_BUILD
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Bootstrap>
COMMENT "Deploy Qt runtime for Bootstrap"
)
endif() endif()
+28
View File
@@ -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
+160 -155
View File
@@ -1,203 +1,208 @@
#include <QApplication> #include <windows.h>
#include <QCoreApplication> #include <shellapi.h>
#include <QDir> #include "resource.h"
#include <QFile> #include <filesystem>
#include <QFileInfo> #include <chrono>
#include <QMessageBox> #include <cstdlib>
#include <QProcess> #include <fstream>
#include <QTextStream> #include <string>
#include <QThread> #include <thread>
#include <QTranslator> #include <vector>
#include <QVector>
struct PlanItem namespace fs = std::filesystem;
{
QChar operation;
QString relativePath;
};
static void showError(const QString& message) static std::wstring loadLocalizedString(unsigned int id)
{ {
QMessageBox::critical(nullptr, const wchar_t* value = nullptr;
QApplication::translate("Bootstrap", "Update Handoff Failed"), const int length = LoadStringW(GetModuleHandleW(nullptr), id,
message); reinterpret_cast<LPWSTR>(&value), 0);
return length > 0 ? std::wstring(value, static_cast<size_t>(length)) : std::wstring();
} }
static QString cleanRelativePath(const QString& value) static void showBootstrapError(unsigned int messageId)
{ {
QString path = QDir::fromNativeSeparators(value.trimmed()); const std::wstring message = loadLocalizedString(messageId);
if (path.isEmpty()) const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
return {}; MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
}
path = QDir::cleanPath(path); static std::wstring quote(const std::wstring& value)
if (path == "." || path == ".." || path.startsWith("../") {
|| path.contains("/../") || QDir::isAbsolutePath(path) std::wstring result = L"\"";
|| path.contains(':')) { unsigned backslashes = 0;
return {}; for (wchar_t ch : value) {
if (ch == L'\\') { ++backslashes; continue; }
if (ch == L'\"') {
result.append(backslashes * 2 + 1, L'\\');
result.push_back(L'\"');
backslashes = 0;
continue;
} }
return path; result.append(backslashes, L'\\');
} backslashes = 0;
result.push_back(ch);
static QString joinPath(const QString& root, const QString& relativePath)
{
return QDir(root).filePath(relativePath);
}
static bool removeWithRetry(const QString& path)
{
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
for (int i = 0; i < 100; ++i) {
if (!QFileInfo::exists(path))
return true;
if (QFile::remove(path))
return true;
QThread::msleep(100);
} }
return !QFileInfo::exists(path); result.append(backslashes * 2, L'\\');
result.push_back(L'\"');
return result;
} }
static bool copyWithRetry(const QString& source, const QString& destination) static std::wstring fromUtf8(const std::string& value)
{ {
QDir().mkpath(QFileInfo(destination).absolutePath()); if (value.empty()) return {};
for (int i = 0; i < 100; ++i) { const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
QFile::remove(destination); static_cast<int>(value.size()), nullptr, 0);
if (QFile::copy(source, destination)) if (size <= 0) return {};
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), result.data(), size);
return result;
}
static bool safeRelative(const fs::path& path)
{
if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
for (const auto& part : path)
if (part == L"..") return false;
return true; return true;
QThread::msleep(100); }
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
{
std::error_code ec;
fs::create_directories(destination.parent_path(), ec);
for (int i = 0; i < 100; ++i) {
ec.clear();
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec);
if (!ec) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
} }
return false; return false;
} }
static bool rollback(const QString& installDir, const QString& backupDir, static bool removeWithRetry(const fs::path& path)
const QVector<QString>& paths) {
std::error_code ec;
for (int i = 0; i < 100; ++i) {
ec.clear();
if (!fs::exists(path, ec)) return !ec;
if (fs::remove(path, ec)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
static bool rollback(const fs::path& installDir, const fs::path& backupDir,
const std::vector<fs::path>& paths)
{ {
bool success = true; bool success = true;
for (const QString& relativePath : paths) { for (const auto& relative : paths) {
const QString destination = joinPath(installDir, relativePath); const fs::path destination = installDir / relative;
const QString backup = joinPath(backupDir, relativePath); const fs::path backup = backupDir / relative;
if (QFileInfo::exists(backup)) { std::error_code ec;
if (!copyWithRetry(backup, destination)) if (fs::exists(backup, ec)) {
success = false; if (!copyWithRetry(backup, destination)) success = false;
} else if (QFileInfo::exists(destination) && !removeWithRetry(destination)) { } else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
success = false; success = false;
} }
} }
return success; return success;
} }
static bool launchUpdater(const QString& updater, const QStringList& updateArgs, static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
const QString& result) const std::wstring& result)
{ {
QStringList args = updateArgs; std::wstring command = quote(updater);
args.append(QStringLiteral("--bootstrap-resume=%1").arg(result)); for (const auto& arg : updateArgs) command += L" " + quote(arg);
return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath()); command += L" " + quote(L"--bootstrap-resume=" + result);
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
std::vector<wchar_t> mutableCommand(command.begin(), command.end());
mutableCommand.push_back(L'\0');
const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr,
FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi);
if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); }
return ok == TRUE;
} }
static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths) int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
{ {
QFile file(planFile); int argc = 0;
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
return false; if (!argv || argc < 11) {
showBootstrapError(IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE);
QTextStream input(&file); if (argv) LocalFree(argv);
input.setCodec("UTF-8");
while (!input.atEnd()) {
const QString line = input.readLine();
if (line.size() < 3 || line.at(1) != QLatin1Char('\t')
|| (line.at(0) != QLatin1Char('C') && line.at(0) != QLatin1Char('D'))) {
return false;
}
const QString relativePath = cleanRelativePath(line.mid(2));
if (relativePath.isEmpty())
return false;
items->append({line.at(0), relativePath});
paths->append(relativePath);
}
return true;
}
static bool isBootstrapSelfPath(const QString& relativePath)
{
const QString fileName = QFileInfo(relativePath).fileName();
return fileName.compare(QStringLiteral("Bootstrap.exe"), Qt::CaseInsensitive) == 0
|| fileName.compare(QStringLiteral("Bootstrap"), Qt::CaseInsensitive) == 0;
}
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
QTranslator translator;
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
app.installTranslator(&translator);
const QStringList arguments = QCoreApplication::arguments();
if (arguments.size() < 11) {
showError(QApplication::translate("Bootstrap", "Bootstrap arguments are incomplete."));
return 2; return 2;
} }
const fs::path planFile = argv[1];
const fs::path installDir = argv[2];
const fs::path stagingDir = argv[3];
const fs::path backupDir = argv[4];
const std::wstring updater = argv[5];
const DWORD updaterPid = static_cast<DWORD>(_wtoi(argv[6]));
std::vector<std::wstring> updateArgs{argv[7], argv[8], argv[9], argv[10]};
const std::wstring mode = argc >= 12 ? argv[11] : L"install";
LocalFree(argv);
const QString planFile = arguments.at(1); if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
const QString installDir = arguments.at(2); WaitForSingleObject(process, 30000);
const QString stagingDir = arguments.at(3); CloseHandle(process);
const QString backupDir = arguments.at(4); }
const QString updater = arguments.at(5);
const QString updaterPid = arguments.at(6);
Q_UNUSED(updaterPid);
const QStringList updateArgs{arguments.at(7), arguments.at(8), arguments.at(9), arguments.at(10)};
const QString mode = arguments.size() >= 12 ? arguments.at(11) : QStringLiteral("install");
QThread::msleep(500); struct PlanItem { wchar_t operation; fs::path relative; };
std::ifstream input(planFile, std::ios::binary);
QVector<PlanItem> items; std::vector<PlanItem> items;
QVector<QString> paths; std::vector<fs::path> paths;
if (!readPlan(planFile, &items, &paths)) { std::string line;
showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid.")); 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')) {
showBootstrapError(IDS_BOOTSTRAP_INVALID_PLAN_OPERATION);
return 3; return 3;
} }
const fs::path relative(fromUtf8(line.substr(2)));
bool success = true; if (!safeRelative(relative)) {
bool rolledBack = false; showBootstrapError(IDS_BOOTSTRAP_UNSAFE_PLAN_PATH);
QString failedPath; return 3;
if (mode == QStringLiteral("rollback")) { }
rolledBack = rollback(installDir, backupDir, paths); items.push_back({static_cast<wchar_t>(line[0]), relative});
success = false; paths.push_back(relative);
} else {
// Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
// 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
for (const PlanItem& item : items) {
const QString& relativePath = item.relativePath;
if (isBootstrapSelfPath(relativePath)) {
success = false;
failedPath = relativePath;
break;
} }
const bool itemOk = item.operation == QLatin1Char('C') bool success = input.eof();
? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath)) bool rolledBack = false;
: removeWithRetry(joinPath(installDir, relativePath)); fs::path failedPath;
if (mode == L"rollback") {
rolledBack = success && rollback(installDir, backupDir, paths);
success = false;
} else if (success) {
for (const auto& item : items) {
const fs::path& relative = item.relative;
if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) {
success = false;
failedPath = relative;
break;
}
const bool itemOk = item.operation == L'C'
? copyWithRetry(stagingDir / relative, installDir / relative)
: removeWithRetry(installDir / relative);
if (!itemOk) { if (!itemOk) {
success = false; success = false;
failedPath = relativePath; failedPath = relative;
break; break;
} }
} }
if (!success) if (!success) rolledBack = rollback(installDir, backupDir, paths);
rolledBack = rollback(installDir, backupDir, paths);
} }
const QString result = success ? QStringLiteral("success") const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
: (rolledBack ? QStringLiteral("rolledback") : QStringLiteral("rollback-failed"));
if (!launchUpdater(updater, updateArgs, result)) { if (!launchUpdater(updater, updateArgs, result)) {
QString message = QApplication::translate("Bootstrap", "Cannot restart Updater."); std::wstring message = loadLocalizedString(IDS_BOOTSTRAP_RESTART_UPDATER_FAILED);
if (!failedPath.isEmpty()) { if (!failedPath.empty())
message += QApplication::translate("Bootstrap", "\nFailed file: %1") message += L"\n" + loadLocalizedString(IDS_BOOTSTRAP_FAILED_FILE_PREFIX) + failedPath.wstring();
.arg(failedPath); const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
} MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
showError(message);
return 4; return 4;
} }
return (success || rolledBack) ? 0 : 5; return (success || rolledBack) ? 0 : 5;
+8
View File
@@ -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
+135 -130
View File
@@ -1,161 +1,166 @@
cmake_minimum_required(VERSION 3.20) 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 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(MSVC)
# Source files should be saved as UTF-8 with BOM; compile source and
# execution charsets as UTF-8 instead of setting encodings in code.
add_compile_options(/utf-8)
endif()
# Qt global config. Qt is intentionally not hard-coded here; configure it with
# CMake-recognized environment variables such as CMAKE_PREFIX_PATH or Qt5_DIR.
set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
get_target_property(QT_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION) if(MSVC)
get_filename_component(QT_BIN_DIR "${QT_QMAKE_EXECUTABLE}" DIRECTORY) add_compile_options(/utf-8)
find_program(QT_LRELEASE_EXECUTABLE NAMES lrelease lrelease.exe HINTS "${QT_BIN_DIR}" REQUIRED) endif()
find_program(QT_LUPDATE_EXECUTABLE NAMES lupdate lupdate.exe HINTS "${QT_BIN_DIR}" REQUIRED)
set(TRANSLATION_TS "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.ts") cmake_path(
set(TRANSLATION_QM "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.qm") ABSOLUTE_PATH UPDATE_CLIENT_SOURCE_DIR
set(TRANSLATION_SCAN_DIRS BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/.."
"${CMAKE_SOURCE_DIR}/Common" NORMALIZE
"${CMAKE_SOURCE_DIR}/Bootstrap" OUTPUT_VARIABLE _update_client_source_root
"${CMAKE_SOURCE_DIR}/Launcher"
"${CMAKE_SOURCE_DIR}/Updater"
"${CMAKE_SOURCE_DIR}/MainApp"
) )
cmake_path(GET _update_client_source_root PARENT_PATH _update_client_parent_root)
add_custom_command( set(
OUTPUT "${TRANSLATION_QM}" UPDATE_CLIENT_THIRDPARTY_ROOT
COMMAND "${QT_LRELEASE_EXECUTABLE}" "${TRANSLATION_TS}" -qm "${TRANSLATION_QM}" "${_update_client_parent_root}/3rdparty"
DEPENDS "${TRANSLATION_TS}" CACHE PATH
COMMENT "Compile Qt translation: update-client_zh_CN.qm" "Root directory for update-client third-party packages"
VERBATIM
) )
add_custom_target(update_client_translations ALL option(
DEPENDS "${TRANSLATION_QM}" UPDATE_CLIENT_STRICT_THIRDPARTY
"Require OpenSSL imported library and include paths to stay under the third-party root"
ON
) )
set(
add_custom_target(update_client_lupdate UPDATE_CLIENT_PRODUCT_CONFIG_FILE
COMMAND "${QT_LUPDATE_EXECUTABLE}" ""
${TRANSLATION_SCAN_DIRS} CACHE FILEPATH
-extensions cpp,h "Exact product config to embed; empty selects config/config.json then the example"
-no-obsolete
-ts "${TRANSLATION_TS}"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Update Qt translation source: update-client_zh_CN.ts"
VERBATIM
) )
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")
# Local-only third-party dependencies. The thirdparty directory is ignored by Git.
# Windows can use thirdparty/OpenSSL-Win64. Linux normally uses system OpenSSL.
set(THIRDPARTY_DIR "${CMAKE_SOURCE_DIR}/thirdparty" CACHE PATH "Local third-party dependency root")
if(WIN32) if(WIN32)
set(SIMCAE_OPENSSL_ROOT "${THIRDPARTY_DIR}/OpenSSL-Win64" CACHE PATH "OpenSSL Win64 root") set(_update_client_deploy_qt_default ON)
if(NOT EXISTS "${SIMCAE_OPENSSL_ROOT}/include/openssl")
message(FATAL_ERROR
"OpenSSL not found: ${SIMCAE_OPENSSL_ROOT}\n"
"Copy OpenSSL-Win64 to thirdparty/OpenSSL-Win64, or configure with "
"-DSIMCAE_OPENSSL_ROOT=<OpenSSL-Win64 root>."
)
endif()
set(OPENSSL_INC "${SIMCAE_OPENSSL_ROOT}/include")
set(OPENSSL_LIB_DEBUG "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MDd")
set(OPENSSL_LIB_RELEASE "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MD")
foreach(OPENSSL_LIB_DIR IN ITEMS "${OPENSSL_LIB_DEBUG}" "${OPENSSL_LIB_RELEASE}")
if(NOT EXISTS "${OPENSSL_LIB_DIR}")
message(FATAL_ERROR "OpenSSL library directory not found: ${OPENSSL_LIB_DIR}")
endif()
endforeach()
set(SIMCAE_OPENSSL_LINK_LIBRARIES
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libssl.lib>
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libcrypto.lib>
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libssl.lib>
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libcrypto.lib>
)
else() else()
find_package(OpenSSL REQUIRED) set(_update_client_deploy_qt_default OFF)
set(OPENSSL_INC "${OPENSSL_INCLUDE_DIR}") endif()
set(SIMCAE_OPENSSL_LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto) 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}
)
option(
UPDATE_CLIENT_BUILD_DEMO_APP
"Build the standalone MainApp integration demo"
${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() endif()
add_compile_definitions(HAVE_OPENSSL=1)
# 统一输出目录。Visual Studio Release 实际输出到 out/bin/ReleaseLinux 单独输出到 out/linux/bin。
if(UNIX AND NOT APPLE)
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out/linux)
else()
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out)
endif()
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
# Bootstrap 使用 Qt 实现跨平台更新交接,避免直接依赖 Win32 API。
add_subdirectory(Bootstrap) add_subdirectory(Bootstrap)
# 先编译公共库
add_subdirectory(Common) add_subdirectory(Common)
# 再编译三个业务程序 add_library(UpdateClientUpdaterLogic STATIC
Updater/UpdaterLogic.h
Updater/UpdaterLogic.cpp
)
target_compile_features(UpdateClientUpdaterLogic PUBLIC cxx_std_17)
target_include_directories(UpdateClientUpdaterLogic PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/Updater"
)
target_link_libraries(UpdateClientUpdaterLogic
PUBLIC
Common
Qt5::Core
Qt5::Network
Qt5::Gui
Qt5::Widgets
)
add_subdirectory(Launcher) add_subdirectory(Launcher)
add_subdirectory(Updater) add_subdirectory(Updater)
add_subdirectory(MainApp)
# 默认启动项目 set(_update_client_primary_targets
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) Bootstrap
Common
# Copy config templates and static resources to output bin folder. UpdateClientUpdaterLogic
if(UNIX AND NOT APPLE AND EXISTS "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json") Launcher
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json") Updater
else() )
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") if(UPDATE_CLIENT_BUILD_DEMO_APP)
add_subdirectory(MainApp)
list(APPEND _update_client_primary_targets MainApp)
endif() endif()
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,则交给客户端首次启动迁移 foreach(_update_client_primary_target IN LISTS _update_client_primary_targets)
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") add_dependencies("${_update_client_primary_target}" UpdateClientNoHanCheck)
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(EXISTS "${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
endif()
endforeach() endforeach()
# 编译阶段同步静态配置资源 set_property(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT Launcher)
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)
add_dependencies(Launcher update_client_translations)
add_dependencies(Updater update_client_translations)
add_dependencies(MainApp update_client_translations)
add_dependencies(Bootstrap update_client_translations)
# Install rules for packaging if(UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS)
if(EXISTS "${APP_CONFIG_SOURCE_FILE}") update_client_install_openssl_runtime(bin)
install(FILES "${APP_CONFIG_SOURCE_FILE}" DESTINATION bin/config RENAME app_config.json)
endif()
if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
endif() endif()
-99
View File
@@ -1,99 +0,0 @@
{
"version": 3,
"configurePresets": [
{
"name": "windows-x64-base",
"displayName": "Windows x64 Base",
"description": "Windows x64 build with Visual Studio 2022.",
"hidden": true,
"generator": "Visual Studio 17 2022",
"architecture": {
"value": "x64",
"strategy": "set"
},
"binaryDir": "${sourceDir}/out/build/${presetName}",
"installDir": "${sourceDir}/out/install/${presetName}",
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
},
{
"name": "linux-x64-base",
"displayName": "Linux x64 Base",
"description": "Linux x64 build with system Qt and OpenSSL.",
"hidden": true,
"generator": "Unix Makefiles",
"binaryDir": "${sourceDir}/out/build/${presetName}",
"installDir": "${sourceDir}/out/install/${presetName}",
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Linux"
}
},
{
"name": "x64-debug",
"displayName": "x64 Debug",
"description": "Debug build for Windows x64.",
"inherits": "windows-x64-base",
"cacheVariables": {
"CMAKE_CONFIGURATION_TYPES": "Debug",
"CMAKE_INSTALL_CONFIG_NAME": "Debug"
}
},
{
"name": "x64-release",
"displayName": "x64 Release",
"description": "Release build for Windows x64.",
"inherits": "windows-x64-base",
"cacheVariables": {
"CMAKE_CONFIGURATION_TYPES": "Release",
"CMAKE_INSTALL_CONFIG_NAME": "Release"
}
},
{
"name": "linux-x64-debug",
"displayName": "Linux x64 Debug",
"description": "Debug build for Linux x64.",
"inherits": "linux-x64-base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "linux-x64-release",
"displayName": "Linux x64 Release",
"description": "Release build for Linux x64.",
"inherits": "linux-x64-base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release"
}
}
],
"buildPresets": [
{
"name": "x64-debug",
"displayName": "x64 Debug",
"configurePreset": "x64-debug",
"configuration": "Debug"
},
{
"name": "x64-release",
"displayName": "x64 Release",
"configurePreset": "x64-release",
"configuration": "Release"
},
{
"name": "linux-x64-debug",
"displayName": "Linux x64 Debug",
"configurePreset": "linux-x64-debug"
},
{
"name": "linux-x64-release",
"displayName": "Linux x64 Release",
"configurePreset": "linux-x64-release"
}
]
}
+16 -16
View File
@@ -1,12 +1,13 @@
project(Common LANGUAGES C CXX) set(_common_sources
set(SRC
HttpHelper.h HttpHelper.h
HttpHelper.cpp HttpHelper.cpp
FileHelper.h FileHelper.h
FileHelper.cpp FileHelper.cpp
ConfigHelper.h ConfigHelper.h
ConfigHelper.cpp ConfigHelper.cpp
InitialStatePolicy.h
ManifestBootstrapPolicy.h
RuntimeProtectionPolicy.h
PolicyHelper.h PolicyHelper.h
PolicyHelper.cpp PolicyHelper.cpp
LocalStateHelper.h LocalStateHelper.h
@@ -17,20 +18,19 @@ set(SRC
IntegrityHelper.cpp IntegrityHelper.cpp
DeviceIdentityHelper.h DeviceIdentityHelper.h
DeviceIdentityHelper.cpp DeviceIdentityHelper.cpp
) TranslationHelper.h
add_library(Common STATIC ${SRC}) TranslationHelper.cpp
# Common编译自身需要OpenSSL头文件
target_include_directories(Common
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${OPENSSL_INC}
) )
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 target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets PUBLIC
PUBLIC ${SIMCAE_OPENSSL_LINK_LIBRARIES} Qt5::Core
Qt5::Network
Qt5::Widgets
OpenSSL::SSL
OpenSSL::Crypto
) )
if(WIN32)
target_link_libraries(Common INTERFACE shell32)
endif()
+568 -778
View File
File diff suppressed because it is too large Load Diff
+15 -25
View File
@@ -1,45 +1,35 @@
#pragma once #pragma once
#include <QByteArray> #include <QJsonObject>
#include <QString> #include <QString>
class QSettings;
class ConfigHelper class ConfigHelper
{ {
public: public:
static ConfigHelper& instance(); static ConfigHelper& instance();
static int runElevatedWriteCommandIfRequested();
static QString executableNameForCurrentPlatform(const QString& configuredValue,
const QString& fallbackBaseName);
static bool writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
QString* errorMessage = nullptr);
static bool removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage = nullptr);
QString getValue(const QString& section, const QString& key) const; QString getValue(const QString& section, const QString& key) const;
bool setValue(const QString& section, const QString& key, const QString& value); bool setValue(const QString& section, const QString& key, const QString& value);
QString configPath() const; QString configPath() const;
QString productConfigPath() const;
QString stateLocation() const;
QString installRoot() const; QString installRoot() const;
QString runtimeRoot() const; QString runtimeRoot() const;
QString dataRoot() const;
QString dataConfigDir() const;
QString clientIdentityPath() const;
QString policyPath() const;
QString localStatePath() const;
QString updateRoot() const; QString updateRoot() const;
QString runtimeRelativePath() const; QString runtimeRelativePath() const;
QString lastError() const; QString lastError() const;
private: private:
ConfigHelper(); ConfigHelper();
bool migrateLegacyIniIfNeeded(); bool loadProductConfig();
void enterRegistryGroup(QSettings& settings) const; bool migrateLegacyStateIfNeeded();
bool syncRegistryFromConfigFileIfChanged(); bool initializeStateDefaults();
bool sanitizeConfigFileAfterImport(QSettings& settings); QString stateStorageKey(const QString& key) const;
bool removeRegistryValue(const QString& key) const; QString stateValue(const QString& key) const;
QString readEmbeddedValue(const QString& key) const; bool writeStateValue(const QString& key, const QString& value);
bool readRegistryValue(const QString& key, QString* value) const; static bool isMutableKey(const QString& key);
bool writeRegistryValue(const QString& key, const QString& value);
QString readFileValue(const QString& key) const; QString m_productConfigPath;
QString m_configPath; QString m_statePrefix;
QString m_registryInstallId; QJsonObject m_productConfig;
QJsonObject m_initialState;
QString m_error; QString m_error;
}; };
+236 -193
View File
@@ -1,18 +1,19 @@
#include "DeviceIdentityHelper.h" #include "DeviceIdentityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCoreApplication>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QEventLoop> #include <QEventLoop>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo> #include <QSysInfo>
#include <QTimer> #include <QTimer>
#include <QUuid> #include <QUuid>
@@ -22,16 +23,18 @@
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
namespace { namespace
QString manifestPublicKeyPath(const QString &installDir)
{ {
return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem")); QString machineHash(const QString& installationId)
{
const QByteArray identity =
QSysInfo::machineUniqueId() + installationId.toUtf8();
return QString::fromLatin1(
QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex());
}
} }
} // namespace DeviceIdentityHelper::DeviceIdentityHelper(const QString& installDir)
DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir)
: m_installDir(installDir) : m_installDir(installDir)
{ {
} }
@@ -46,276 +49,316 @@ QString DeviceIdentityHelper::errorString() const
return m_error; return m_error;
} }
bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64) bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,
const QString& signatureBase64)
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(payload);
Q_UNUSED(signatureBase64); Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate( m_error = "OpenSSL unavailable";
"DeviceIdentityHelper",
"OpenSSL is unavailable, so device credential signature cannot be verified.");
return false; return false;
#else #else
const QString keyPath = manifestPublicKeyPath(m_installDir); QFile keyFile(
QFile keyFile(keyPath); QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly)) { if (!keyFile.open(QIODevice::ReadOnly))
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath); {
m_error = "embedded device public key missing";
return false; return false;
} }
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio
if (bio) { ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)
: nullptr;
if (bio)
BIO_free(bio); BIO_free(bio);
} if (!key)
if (!publicKey) { {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath); m_error = "device public key invalid";
return false; return false;
} }
EVP_MD_CTX *ctx = EVP_MD_CTX_new(); EVP_MD_CTX* context = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature =
const bool ok = ctx QByteArray::fromBase64(signatureBase64.toUtf8());
&& EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1 const bool valid =
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 context
&& EVP_DigestVerifyInit(
context, nullptr, EVP_sha256(), nullptr, key)
== 1
&& EVP_DigestVerifyUpdate(
context, payload.constData(), payload.size())
== 1
&& EVP_DigestVerifyFinal( && EVP_DigestVerifyFinal(
ctx, context,
reinterpret_cast<const unsigned char *>(signature.constData()), reinterpret_cast<const unsigned char*>(signature.constData()),
signature.size()) == 1; signature.size())
== 1;
if (ctx) { if (context)
EVP_MD_CTX_free(ctx); EVP_MD_CTX_free(context);
} EVP_PKEY_free(key);
EVP_PKEY_free(publicKey); if (!valid)
m_error = "device credential RSA signature invalid";
if (!ok) { return valid;
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential signature is invalid. The local identity file may not match this server.");
}
return ok;
#endif #endif
} }
bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel) bool DeviceIdentityHelper::loadAndVerify(const QString& expectedAppId,
const QString& expectedChannel)
{ {
// client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。 QFile credential(
// 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。 QDir(m_installDir).filePath("config/client_identity.dat"));
QString credentialPath = ConfigHelper::instance().clientIdentityPath(); if (!credential.open(QIODevice::ReadOnly))
if (!QFile::exists(credentialPath)) { {
credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat")); m_error = "device credential is missing or unreadable";
}
QFile credentialFile(credentialPath);
if (!credentialFile.open(QIODevice::ReadOnly)) {
return false; return false;
} }
QJsonParseError parseError; QJsonParseError wrapperError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError); const QJsonDocument wrapperDocument =
if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { QJsonDocument::fromJson(credential.readAll(), &wrapperError);
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1") if (wrapperError.error != QJsonParseError::NoError
.arg(credentialPath); || !wrapperDocument.isObject())
{
m_error = "device credential JSON invalid";
return false; return false;
} }
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDocument.object();
const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8(); const QByteArray identityText =
const QString signature = wrapper.value(QStringLiteral("signature")).toString(); wrapper.value("identity_text").toString().toUtf8();
if (identityText.isEmpty() || !verifySignature(identityText, signature)) { if (identityText.isEmpty()
|| !verifySignature(
identityText, wrapper.value("signature").toString()))
{
return false;
}
QJsonParseError identityError;
const QJsonDocument identityDocument =
QJsonDocument::fromJson(identityText, &identityError);
if (identityError.error != QJsonParseError::NoError
|| !identityDocument.isObject())
{
m_error = "signed device identity JSON invalid";
return false;
}
const QJsonObject identity = identityDocument.object();
const QString signedInstallationId =
identity.value("installation_id").toString();
const QString signedDeviceId = identity.value("device_id").toString();
ConfigHelper& config = ConfigHelper::instance();
const QString localInstallationId =
config.getValue("Device", "installation_id").trimmed();
const QString localDeviceId =
config.getValue("Update", "device_id").trimmed();
if (identity.value("app_id").toString() != expectedAppId
|| identity.value("channel").toString() != expectedChannel
|| identity.value("license_id").toString().isEmpty()
|| signedInstallationId.isEmpty() || signedDeviceId.isEmpty())
{
m_error = "device/license credential identity mismatch";
return false;
}
if (localInstallationId.isEmpty()
|| signedInstallationId != localInstallationId)
{
m_error = "device credential belongs to another installation";
return false;
}
if (!localDeviceId.isEmpty() && signedDeviceId != localDeviceId)
{
m_error = "device credential does not match the registered device";
return false;
}
const QString signedMachineHash =
identity.value("machine_hash").toString();
if (!signedMachineHash.isEmpty()
&& signedMachineHash.compare(
machineHash(localInstallationId), Qt::CaseInsensitive)
!= 0)
{
m_error = "device credential belongs to another machine";
return false; return false;
} }
const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText);
const QJsonObject identity = identityDoc.object();
const QDateTime expiry = QDateTime::fromString( const QDateTime expiry = QDateTime::fromString(
identity.value(QStringLiteral("valid_until")).toString(), identity.value("valid_until").toString(), Qt::ISODate);
Qt::ISODate); if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc())
{
const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId m_error = "license expired";
&& identity.value(QStringLiteral("channel")).toString() == expectedChannel
&& !identity.value(QStringLiteral("license_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("installation_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("device_id")).toString().isEmpty();
if (!identityMatches) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential does not match this application, channel, license, installation or device.");
return false; return false;
} }
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) { m_deviceId = signedDeviceId;
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License has expired. Please ask the administrator to issue a new License.");
return false;
}
m_deviceId = identity.value(QStringLiteral("device_id")).toString();
return true; return true;
} }
bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel) bool DeviceIdentityHelper::verifyLocal(const QString& appId,
const QString& channel)
{ {
m_error.clear(); m_error.clear();
m_deviceId.clear();
return loadAndVerify(appId, channel); return loadAndVerify(appId, channel);
} }
bool DeviceIdentityHelper::ensureIssued( bool DeviceIdentityHelper::ensureIssued(const QString& apiBaseUrl,
const QString &apiBaseUrl, const QString& clientToken,
const QString &clientToken, const QString& appId,
const QString &appId, const QString& channel,
const QString &channel, const QString& licenseKey)
const QString &licenseKey)
{ {
// 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。
// 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。
m_error.clear(); m_error.clear();
if (loadAndVerify(appId, channel)) { m_deviceId.clear();
ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId); 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; return true;
} }
const QString trimmedBaseUrl = apiBaseUrl.trimmed(); const QString server = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty()) { if (appId.trimmed().isEmpty())
m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json."); {
m_error = "app_id is empty in the embedded product configuration";
return false; return false;
} }
if (channel.trimmed().isEmpty()) { if (channel.trimmed().isEmpty())
m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json."); {
m_error = "channel is empty in the embedded product configuration";
return false; return false;
} }
if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) { if (server.isEmpty()
m_error = QCoreApplication::translate( || server.contains("YOUR_SERVER_IP", Qt::CaseInsensitive))
"DeviceIdentityHelper", {
"Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000"); m_error = "api_base_url is not configured";
return false; return false;
} }
if (clientToken.trimmed().isEmpty()) { if (clientToken.trimmed().isEmpty())
m_error = QCoreApplication::translate( {
"DeviceIdentityHelper", m_error = "client_token is empty in the embedded product configuration";
"client_token is empty. Copy the client_token generated by the admin page into app_config.json.");
return false; return false;
} }
if (licenseKey.trimmed().isEmpty()) { if (licenseKey.trimmed().isEmpty())
m_error = QCoreApplication::translate( {
"DeviceIdentityHelper", m_error = "license_key is empty";
"License is empty. Create or select a License in the admin page, then copy the generated client configuration.");
return false; return false;
} }
ConfigHelper &config = ConfigHelper::instance(); QString installationId =
QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")); config.getValue("Device", "installation_id").trimmed();
if (installationId.isEmpty()) { if (installationId.isEmpty())
installationId = QUuid::createUuid().toString(QUuid::WithoutBraces); {
if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) { installationId =
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2") 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()); .arg(config.configPath(), config.lastError());
return false; return false;
} }
} }
const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8(); const QJsonObject requestBody{
const QString machineHash = QString::fromLatin1( {"app_id", appId},
QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex()); {"channel", channel},
const QJsonObject body{ {"license_key", licenseKey},
{QStringLiteral("app_id"), appId}, {"installation_id", installationId},
{QStringLiteral("channel"), channel}, {"machine_hash", machineHash(installationId)}};
{QStringLiteral("license_key"), licenseKey},
{QStringLiteral("installation_id"), installationId},
{QStringLiteral("machine_hash"), machineHash},
};
QNetworkAccessManager manager; QNetworkAccessManager manager;
QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))}; QNetworkRequest request{QUrl(server + "/api/v1/device/issue")};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("X-Client-Token", clientToken.toUtf8()); request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply* reply = manager.post(
request, QJsonDocument(requestBody).toJson(QJsonDocument::Compact));
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
QEventLoop loop; QEventLoop loop;
bool timeoutValid = false;
int timeoutMs =
config.getValue("Update", "request_timeout_ms").toInt(&timeoutValid);
if (!timeoutValid || timeoutMs < 1000)
timeoutMs = 5000;
QTimer timer; QTimer timer;
timer.setSingleShot(true); timer.setSingleShot(true);
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance()
.getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms"))
.toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) {
timeoutMs = 5000;
}
QObject::connect(&timer, &QTimer::timeout, [&]() { QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) { if (reply && reply->isRunning())
reply->abort(); reply->abort();
}
}); });
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); QObject::connect(reply, &QNetworkReply::finished,
&loop, &QEventLoop::quit);
timer.start(timeoutMs); timer.start(timeoutMs);
loop.exec(); loop.exec();
timer.stop(); timer.stop();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray responseBytes = reply->readAll();
const QString networkError = reply->errorString(); const QString networkError = reply->errorString();
const QByteArray raw = reply->readAll();
reply->deleteLater(); reply->deleteLater();
if (status != 200) { if (status != 200)
{
m_error = QString("device issue failed (HTTP %1): %2 %3")
.arg(status)
.arg(networkError, QString::fromUtf8(responseBytes));
return false;
}
QJsonParseError responseError; QJsonParseError responseError;
const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError); const QJsonDocument responseDocument =
QString serverMessage; QJsonDocument::fromJson(responseBytes, &responseError);
if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) { if (responseError.error != QJsonParseError::NoError
const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail")); || !responseDocument.isObject())
serverMessage = detail.isObject() {
? detail.toObject().value(QStringLiteral("msg")).toString() m_error = "device issue response is invalid JSON";
: detail.toString();
}
if (serverMessage.isEmpty())
serverMessage = QString::fromUtf8(raw).trimmed();
if (status == 0) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms")
.arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs));
} else {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5")
.arg(trimmedBaseUrl, QString::number(status), appId, channel,
serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "<empty response>") : serverMessage);
}
return false; return false;
} }
const QJsonObject response = responseDocument.object();
const QJsonDocument responseDoc = QJsonDocument::fromJson(raw);
const QJsonObject response = responseDoc.object();
if (response.value(QStringLiteral("identity_text")).toString().isEmpty()
|| response.value(QStringLiteral("signature")).toString().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server returned an invalid device identity response.");
return false;
}
const QJsonObject wrapper{ const QJsonObject wrapper{
{QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))}, {"identity_text", response.value("identity_text")},
{QStringLiteral("signature"), response.value(QStringLiteral("signature"))}, {"signature", response.value("signature")}};
}; if (!wrapper.value("identity_text").isString()
const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); || wrapper.value("identity_text").toString().isEmpty()
const QString credentialPath = config.clientIdentityPath(); || !wrapper.value("signature").isString()
|| wrapper.value("signature").toString().isEmpty())
{
m_error = "device issue response is missing signed identity fields";
return false;
}
QString writeError; const QString credentialPath =
if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) { QDir(m_installDir).filePath("config/client_identity.dat");
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2") if (!QDir().mkpath(QFileInfo(credentialPath).path()))
.arg(credentialPath, writeError); {
m_error = "cannot create the device credential directory";
return false; return false;
} }
if (!loadAndVerify(appId, channel)) { 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; return false;
} }
if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2") 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()); .arg(config.configPath(), config.lastError());
return false; return false;
} }
+7 -20
View File
@@ -1,28 +1,15 @@
#pragma once #pragma once
#include <QByteArray>
#include <QString> #include <QString>
class DeviceIdentityHelper { class DeviceIdentityHelper {
public: public:
explicit DeviceIdentityHelper(const QString &installDir); explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
bool ensureIssued( const QString& channel, const QString& licenseKey);
const QString &apiBaseUrl, bool verifyLocal(const QString& appId, const QString& channel);
const QString &clientToken,
const QString &appId,
const QString &channel,
const QString &licenseKey);
bool verifyLocal(const QString &appId, const QString &channel);
QString deviceId() const; QString deviceId() const;
QString errorString() const; QString errorString() const;
private: private:
bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel); bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray &payload, const QString &signatureBase64); bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error;
QString m_installDir;
QString m_deviceId;
QString m_error;
}; };
+7 -62
View File
@@ -1,20 +1,5 @@
#include "FileHelper.h" #include "FileHelper.h"
#include <QDebug> #include <QDebug>
#include <QFileInfo>
#include <QThread>
namespace
{
QString processImageName(const QString& exeName)
{
QString name = QFileInfo(exeName).fileName().trimmed();
#ifndef Q_OS_WIN
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
name.chop(4);
#endif
return name;
}
}
bool FileHelper::createDir(const QString &path) bool FileHelper::createDir(const QString &path)
{ {
@@ -30,7 +15,7 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
{ {
if (!QFile::remove(dst)) if (!QFile::remove(dst))
{ {
qDebug() << "Cannot remove old file:" << dst; qDebug() << "Cannot remove the old file:" << dst;
return false; return false;
} }
} }
@@ -39,59 +24,19 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
bool FileHelper::isProcessRunning(const QString &exeName) bool FileHelper::isProcessRunning(const QString &exeName)
{ {
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return false;
QProcess process; QProcess process;
#ifdef Q_OS_WIN process.start("tasklist");
process.start(QStringLiteral("tasklist"), QStringList{
QStringLiteral("/FI"),
QStringLiteral("IMAGENAME eq %1").arg(imageName)
});
process.waitForFinished(); process.waitForFinished();
const QString output = QString::fromLocal8Bit(process.readAllStandardOutput()); QString output = process.readAllStandardOutput();
return output.contains(imageName, Qt::CaseInsensitive); return output.contains(exeName, Qt::CaseInsensitive);
#elif defined(Q_OS_UNIX)
process.start(QStringLiteral("pgrep"), QStringList{
QStringLiteral("-x"),
imageName
});
process.waitForFinished(1000);
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
#else
return false;
#endif
} }
bool FileHelper::killProcess(const QString &exeName) bool FileHelper::killProcess(const QString &exeName)
{ {
if (!isProcessRunning(exeName)) if (!isProcessRunning(exeName))
return true; return true;
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return true;
QProcess process; QProcess process;
#ifdef Q_OS_WIN process.start("taskkill /f /im " + exeName);
process.start(QStringLiteral("taskkill"), QStringList{ process.waitForFinished(1000);
QStringLiteral("/f"), return !isProcessRunning(exeName);
QStringLiteral("/im"),
imageName
});
#elif defined(Q_OS_UNIX)
process.start(QStringLiteral("pkill"), QStringList{
QStringLiteral("-x"),
imageName
});
#else
return false;
#endif
process.waitForFinished(3000);
for (int i = 0; i < 20; ++i) {
if (!isProcessRunning(imageName))
return true;
QThread::msleep(100);
}
return false;
} }
+1 -4
View File
@@ -17,10 +17,7 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
// Add auth token header // Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token"); QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8()); req.setRawHeader("X-Client-Token", token.toUtf8());
QString identityPath = ConfigHelper::instance().clientIdentityPath(); QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
if (!QFile::exists(identityPath))
identityPath = QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat");
QFile identity(identityPath);
if (identity.open(QIODevice::ReadOnly)) if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
+1 -1
View File
@@ -11,7 +11,7 @@
class HttpHelper class HttpHelper
{ {
public: public:
// Create an independent manager for each call instead of keeping it as a member. // Each request owns its manager so synchronous calls cannot share reply state.
static void postRequest(const QString& url, const QJsonObject& jsonBody, static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback); std::function<void(int code, const QJsonObject& resp)> callback);
}; };
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <QString>
namespace UpdateClient
{
inline bool shouldApplyInitialStateValue(const QString& key,
bool storedValueExists,
const QString& storedValue,
const QString& initialValue)
{
if (!storedValueExists)
return true;
return key == QStringLiteral("license_key")
&& storedValue.trimmed().isEmpty()
&& !initialValue.trimmed().isEmpty();
}
}
+27 -94
View File
@@ -1,12 +1,12 @@
#include "IntegrityHelper.h" #include "IntegrityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "RuntimeProtectionPolicy.h"
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QCoreApplication>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QSet> #include <QSet>
@@ -20,6 +20,11 @@ IntegrityHelper::IntegrityHelper(const QString& installDir)
QString IntegrityHelper::errorString() const { return m_error; } QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::isManifestCacheMissing() const
{
return m_manifestCacheMissing;
}
bool IntegrityHelper::safeRelativePath(const QString& path) const bool IntegrityHelper::safeRelativePath(const QString& path) const
{ {
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
@@ -29,23 +34,8 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{ {
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。 return UpdateClient::isRuntimeProtectedPath(
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。 path, ConfigHelper::instance().runtimeRelativePath());
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(p);
} }
QString IntegrityHelper::sha256(const QString& filePath) const QString IntegrityHelper::sha256(const QString& filePath) const
@@ -60,31 +50,15 @@ QString IntegrityHelper::sha256(const QString& filePath) const
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
return false;
#else #else
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem"); QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!QFile::exists(keyPath)) if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "embedded manifest public key missing"; return false; }
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio); if (bio) BIO_free(bio);
if (!key) { if (!key) { m_error = "manifest public key invalid"; return false; }
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
@@ -92,10 +66,7 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; && EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key); EVP_PKEY_free(key);
if (!ok) { if (!ok) m_error = "manifest RSA signature invalid";
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
}
return ok; return ok;
#endif #endif
} }
@@ -104,87 +75,54 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
const QString& version) const QString& version)
{ {
m_error.clear(); m_error.clear();
// Manifest cache 来自服务端发布版本时生成的签名清单。 m_manifestCacheMissing = false;
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath( QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
"manifest_cache/manifest_" + version + ".json"); "manifest_cache/manifest_" + version + ".json");
const QString legacyCachePath = QDir(m_installDir).filePath( const QString legacyCachePath = QDir(m_installDir).filePath(
"update/manifest_cache/manifest_" + version + ".json"); "update/manifest_cache/manifest_" + version + ".json");
if (!QFile::exists(cachePath)) if (!QFile::exists(cachePath))
cachePath = legacyCachePath; cachePath = legacyCachePath;
QFile cache(cachePath); if (!QFile::exists(cachePath)) {
if (!cache.open(QIODevice::ReadOnly)) { m_manifestCacheMissing = true;
m_error = QCoreApplication::translate("IntegrityHelper", m_error = "signed manifest cache missing for " + version;
"Local signed manifest cache is missing. Stage: installed version verification. Version: %1. Expected cache file: %2. This cache is created after the same version is published or installed successfully.")
.arg(version, cachePath);
return false; return false;
} }
QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) { m_error = "cannot read signed manifest cache for " + version; return false; }
QJsonParseError wrapperError; QJsonParseError wrapperError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = QCoreApplication::translate("IntegrityHelper", m_error = "manifest cache JSON invalid"; return false;
"Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, wrapperError.errorString());
return false;
} }
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty()) { if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath);
return false;
}
if (!verifySignature(manifestText, signature)) return false;
QJsonParseError manifestError; QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
m_error = QCoreApplication::translate("IntegrityHelper", m_error = "signed manifest payload invalid"; return false;
"Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, manifestError.errorString());
return false;
} }
const QJsonObject manifest = manifestDoc.object(); const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel || manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) { || manifest.value("version").toString() != version) {
m_error = QCoreApplication::translate("IntegrityHelper", m_error = "manifest identity does not match local application"; return false;
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
} }
QSet<QString> declaredExecutables; QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) { for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject(); const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; }
m_error = QCoreApplication::translate("IntegrityHelper",
"Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
.arg(version, path);
return false;
}
if (runtimeProtectedPath(path)) continue; if (runtimeProtectedPath(path)) continue;
const QString fullPath = QDir(m_installDir).filePath(path); const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) { if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; }
m_error = QCoreApplication::translate("IntegrityHelper",
"A required installed file is missing. Stage: installed version verification. Version: %1. Manifest path: %2. Checked path: %3. The local installation no longer matches the published version.")
.arg(version, path, fullPath);
return false;
}
const QString expected = item.value("sha256").toString(); const QString expected = item.value("sha256").toString();
const QString actual = sha256(fullPath); const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("IntegrityHelper", m_error = "file hash mismatch: " + path; return false;
"Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected SHA-256: %4\nActual SHA-256: %5\nThis means the installed file is different from the version that was published or installed. If this is a developer test machine, check whether the local Release directory was recompiled or overwritten after publishing.")
.arg(version, path, fullPath, expected,
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
return false;
} }
const QString suffix = QFileInfo(path).suffix().toCaseFolded(); const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
@@ -192,8 +130,6 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
QDir root(m_installDir); QDir root(m_installDir);
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories); QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
// 这能降低被人偷偷塞插件或可执行文件的风险。
while (it.hasNext()) { while (it.hasNext()) {
const QString fullPath = it.next(); const QString fullPath = it.next();
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath)); const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
@@ -206,10 +142,7 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue; || runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = QCoreApplication::translate("IntegrityHelper", m_error = "undeclared executable or plugin: " + relative; return false;
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
return false;
} }
} }
return true; return true;
+2
View File
@@ -7,6 +7,7 @@ public:
explicit IntegrityHelper(const QString& installDir); explicit IntegrityHelper(const QString& installDir);
bool verifyInstalledVersion(const QString& appId, const QString& channel, bool verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version); const QString& version);
bool isManifestCacheMissing() const;
QString errorString() const; QString errorString() const;
private: private:
@@ -17,4 +18,5 @@ private:
QString m_installDir; QString m_installDir;
QString m_error; QString m_error;
bool m_manifestCacheMissing = false;
}; };
+19 -35
View File
@@ -1,28 +1,28 @@
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QDir>
LocalStateHelper::LocalStateHelper(const QString& baseDir) LocalStateHelper::LocalStateHelper(const QString& baseDir)
: m_baseDir(baseDir) : m_baseDir(baseDir)
, m_filePath(ConfigHelper::instance().localStatePath()) , m_filePath(baseDir + "/config/local_state.json")
{ {
} }
bool LocalStateHelper::loadState(const QString& relativePath) bool LocalStateHelper::loadState(const QString& relativePath)
{ {
const QString defaultState = QStringLiteral("config/local_state.json");
if (relativePath == defaultState)
m_filePath = ConfigHelper::instance().localStatePath();
else if (QDir::isAbsolutePath(relativePath))
m_filePath = relativePath;
else
m_filePath = m_baseDir + "/" + relativePath; m_filePath = m_baseDir + "/" + relativePath;
QFile file(m_filePath); QFile file(m_filePath);
if (!file.exists()) if (!file.exists())
{ {
QDir dir(QFileInfo(m_filePath).path());
if (!dir.exists() && !dir.mkpath("."))
{
m_error = QString("Cannot create state directory: %1").arg(dir.path());
return false;
}
m_state = QJsonObject{ m_state = QJsonObject{
{"max_policy_seq", 0}, {"max_policy_seq", 0},
{"last_success_run_at", QString()}, {"last_success_run_at", QString()},
@@ -32,10 +32,7 @@ bool LocalStateHelper::loadState(const QString& relativePath)
m_loaded = true; m_loaded = true;
if (!saveState()) if (!saveState())
{ {
m_error = QCoreApplication::translate( m_error = QString("Cannot write new state file: %1").arg(m_filePath);
"LocalStateHelper",
"Cannot create local state file: %1. Error: %2.")
.arg(m_filePath, m_error);
return false; return false;
} }
return true; return true;
@@ -43,10 +40,7 @@ bool LocalStateHelper::loadState(const QString& relativePath)
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
{ {
m_error = QCoreApplication::translate( m_error = QString("Cannot open state file: %1").arg(m_filePath);
"LocalStateHelper",
"Cannot open local state file: %1. Error: %2.")
.arg(m_filePath, file.errorString());
return false; return false;
} }
@@ -57,10 +51,7 @@ bool LocalStateHelper::loadState(const QString& relativePath)
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) if (parseError.error != QJsonParseError::NoError || !doc.isObject())
{ {
m_error = QCoreApplication::translate( m_error = QString("Invalid state JSON: %1").arg(parseError.errorString());
"LocalStateHelper",
"Local state file is not valid JSON. File: %1. JSON error: %2.")
.arg(m_filePath, parseError.errorString());
return false; return false;
} }
@@ -72,24 +63,17 @@ bool LocalStateHelper::loadState(const QString& relativePath)
bool LocalStateHelper::saveState() const bool LocalStateHelper::saveState() const
{ {
if (!m_loaded) if (!m_loaded)
return false;
QFile file(m_filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
{ {
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state has not been loaded.");
return false; return false;
} }
QJsonDocument doc(m_state); QJsonDocument doc(m_state);
QString writeError; file.write(doc.toJson(QJsonDocument::Indented));
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError)) file.close();
{
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot save local state file: %1. Error: %2.")
.arg(m_filePath, writeError);
return false;
}
m_error.clear();
return true; return true;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ public:
private: private:
QString m_baseDir; QString m_baseDir;
QString m_filePath; QString m_filePath;
mutable QString m_error; QString m_error;
QJsonObject m_state; QJsonObject m_state;
bool m_loaded = false; bool m_loaded = false;
}; };
+24
View File
@@ -0,0 +1,24 @@
#pragma once
namespace UpdateClient
{
enum class ManifestBootstrapAction
{
None,
FetchCurrentManifest,
CurrentVersionNotPublished
};
inline ManifestBootstrapAction manifestBootstrapAction(
bool manifestCacheMissing,
bool networkAvailable,
bool updateAvailable,
int serverVersionId)
{
if (!manifestCacheMissing || !networkAvailable || updateAvailable)
return ManifestBootstrapAction::None;
if (serverVersionId <= 0)
return ManifestBootstrapAction::CurrentVersionNotPublished;
return ManifestBootstrapAction::FetchCurrentManifest;
}
}
+58 -91
View File
@@ -1,12 +1,10 @@
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDir>
#include <QFile> #include <QFile>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile>
#include <algorithm> #include <algorithm>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
@@ -15,62 +13,58 @@
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {} PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
static QString resolvePolicyPath(const QString& baseDir, const QString& relativePath, bool forWrite)
{
const QString defaultPolicy = QStringLiteral("config/version_policy.dat");
if (relativePath == defaultPolicy) {
const QString runtimePolicy = ConfigHelper::instance().policyPath();
if (forWrite || QFile::exists(runtimePolicy))
return runtimePolicy;
}
if (QDir::isAbsolutePath(relativePath))
return relativePath;
return baseDir + "/" + relativePath;
}
bool PolicyHelper::loadPolicy(const QString& relativePath) bool PolicyHelper::loadPolicy(const QString& relativePath)
{ {
const QString path = resolvePolicyPath(m_baseDir, relativePath, false); QFile file(m_baseDir + "/" + relativePath);
QFile file(path); if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
if (!file.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open signed version policy file: %1. Error: %2.")
.arg(path, file.errorString());
return false;
}
QJsonParseError error; QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !doc.isObject()) { if (error.error != QJsonParseError::NoError || !doc.isObject()) {
m_error = QCoreApplication::translate( m_error = "Invalid policy JSON: " + error.errorString(); return false;
"PolicyHelper",
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
.arg(path, 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) 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(); return isValid();
} }
bool PolicyHelper::savePolicy(const QString& relativePath) const bool PolicyHelper::savePolicy(const QString& relativePath) const
{ {
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented); QSaveFile file(m_baseDir + "/" + relativePath);
QString writeError; if (!file.open(QIODevice::WriteOnly)) return false;
const QString path = resolvePolicyPath(m_baseDir, relativePath, true); QJsonObject stored = m_policy;
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) { if (!m_signedText.isEmpty())
m_error = QCoreApplication::translate( {
"PolicyHelper", stored = QJsonObject{{"policy", m_policy}, {"policy_text", m_signedText}};
"Cannot save signed version policy file: %1. Error: %2.")
.arg(path, writeError);
return false;
} }
m_error.clear(); const QByteArray bytes = QJsonDocument(stored).toJson(QJsonDocument::Indented);
return true; return file.write(bytes) == bytes.size() && file.commit();
} }
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
@@ -100,75 +94,49 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"PolicyHelper",
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
return false;
#else #else
QString keyPath = m_baseDir + "/config/manifest_public_key.pem"; QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
QFile keyFile(keyPath); if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open embedded policy public key"; return false; }
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open version policy public key: %1. Error: %2.")
.arg(keyPath, keyFile.errorString());
return false;
}
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio); if (bio) BIO_free(bio);
if (!key) { if (!key) { m_error = "Invalid policy public key"; return false; }
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy public key is invalid: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; && EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) { if (!ok) m_error = "Invalid RSA policy signature";
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
}
return ok; return ok;
#endif #endif
} }
bool PolicyHelper::isValid() const bool PolicyHelper::isValid() const
{ {
if (!m_loaded) { if (!m_loaded) return false;
m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
return false;
}
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run", const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"}; "force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
for (const QString& key : required) { for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; }
if (!m_policy.contains(key)) { if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy is missing required field: %1.")
.arg(key);
return false;
}
}
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy signature algorithm is unsupported: %1.")
.arg(m_policy.value("signature_alg").toString());
return false;
}
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString()); 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; } QString PolicyHelper::errorString() const { return m_error; }
bool PolicyHelper::isVersionAllowed(const QString& version) const { bool PolicyHelper::isVersionAllowed(const QString& version) const {
if (!isValid()) return false; if (!isValid()) return false;
@@ -179,7 +147,6 @@ bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); } bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); } bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); } bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
bool PolicyHelper::gitTagsEnabled() const { return isValid() && m_policy.value("git_tags_enabled").toBool(); }
bool PolicyHelper::isExpired() const { bool PolicyHelper::isExpired() const {
if (!isValid()) return true; if (!isValid()) return true;
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate); const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
+2 -1
View File
@@ -11,13 +11,14 @@ public:
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString()); bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const; bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
bool isValid() const; bool isValid() const;
bool isApplicable(const QString& appId, const QString& channel,
const QString& currentVersion) const;
QString errorString() const; QString errorString() const;
bool isVersionAllowed(const QString& currentVersion) const; bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const; bool allowRun() const;
bool forceUpdate() const; bool forceUpdate() const;
bool allowRollback() const; bool allowRollback() const;
bool isOfflineAllowed() const; bool isOfflineAllowed() const;
bool gitTagsEnabled() const;
bool isExpired() const; bool isExpired() const;
qint64 policySeq() const; qint64 policySeq() const;
QString message() const; QString message() const;
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <QDir>
#include <QSet>
#include <QString>
namespace UpdateClient
{
inline bool isRuntimeProtectedPath(const QString& path,
const QString& runtimeRelativePath)
{
const QString normalizedPath =
QDir::cleanPath(QDir::fromNativeSeparators(path)).toCaseFolded();
static const QSet<QString> installerManagedRootFiles{
QStringLiteral("components.xml"),
QStringLiteral("installationlog.txt"),
QStringLiteral("installer.dat"),
QStringLiteral("maintenancetool.dat"),
QStringLiteral("maintenancetool.exe"),
QStringLiteral("maintenancetool.ini"),
QStringLiteral("network.xml")
};
if (installerManagedRootFiles.contains(normalizedPath)
|| normalizedPath.startsWith(QStringLiteral("installerresources/"))
|| normalizedPath.startsWith(QStringLiteral("licenses/")))
return true;
static const QSet<QString> runtimeProtectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
if (runtimeProtectedPaths.contains(normalizedPath))
return true;
QString runtimePrefix = QDir::cleanPath(
QDir::fromNativeSeparators(runtimeRelativePath)).toCaseFolded();
if (runtimePrefix.isEmpty() || runtimePrefix == QStringLiteral("."))
return false;
while (runtimePrefix.startsWith(QStringLiteral("./")))
runtimePrefix.remove(0, 2);
for (const QString& protectedPath : runtimeProtectedPaths) {
if (normalizedPath == runtimePrefix + QLatin1Char('/') + protectedPath)
return true;
}
return false;
}
}
+67 -104
View File
@@ -3,12 +3,12 @@
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QMessageAuthenticationCode> #include <QMessageAuthenticationCode>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
#include <QStringList>
#include <QUuid> #include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
@@ -17,24 +17,58 @@ static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex(); 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, bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret, const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage) QString* ticketPath, QString* errorMessage)
{ {
// Launcher 启动业务主程序前生成一次性 ticket。
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) { if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) { if (errorMessage) *errorMessage = "ticket identity or secret is empty";
QStringList missing;
if (appId.isEmpty()) missing.append(QStringLiteral("app_id"));
if (deviceId.isEmpty()) missing.append(QStringLiteral("device_id"));
if (version.isEmpty()) missing.append(QStringLiteral("current_version"));
if (secret.isEmpty()) missing.append(QStringLiteral("launch_token"));
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket because required fields are empty: %1. Check app_config.json, registry-imported configuration and device authorization.")
.arg(missing.join(QStringLiteral(", ")));
}
return false; return false;
} }
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
@@ -47,25 +81,12 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
}; };
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}}; QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets"); const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) { if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket directory: %1.")
.arg(dirPath);
}
return false;
}
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
QSaveFile file(path); QSaveFile file(path);
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
if (errorMessage) { if (errorMessage) *errorMessage = "cannot save ticket";
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot save launch ticket file: %1. Error: %2.")
.arg(path, file.errorString());
}
return false; return false;
} }
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
@@ -77,42 +98,29 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const QString& expectedDeviceId, const QString& expectedVersion, const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage) const QString& secret, QString* errorMessage)
{ {
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。 if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty()
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。 || expectedVersion.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete";
return false;
}
const QString consumingPath = ticketPath + ".consuming." const QString consumingPath = ticketPath + ".consuming."
+ QString::number(QCoreApplication::applicationPid()); + QString::number(QCoreApplication::applicationPid());
if (!QFile::rename(ticketPath, consumingPath)) { if (!QFile::rename(ticketPath, consumingPath)) {
if (errorMessage) { if (errorMessage) *errorMessage = "ticket missing or already consumed";
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is missing or has already been consumed. Ticket file: %1. Please start the application from Launcher.")
.arg(ticketPath);
}
return false; return false;
} }
QFile file(consumingPath); QFile file(consumingPath);
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(consumingPath); QFile::remove(consumingPath);
if (errorMessage) { if (errorMessage) *errorMessage = "cannot read claimed ticket";
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot read claimed launch ticket: %1. Error: %2.")
.arg(consumingPath, file.errorString());
}
return false; return false;
} }
const QByteArray raw = file.readAll(); file.close(); const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure. QFile::remove(consumingPath); // Consume once so neither successful nor failed claims can be replayed.
QJsonParseError parseError; QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
if (errorMessage) { if (errorMessage) *errorMessage = "invalid ticket JSON"; return false;
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is not valid JSON. JSON error: %1.")
.arg(parseError.errorString());
}
return false;
} }
const QJsonObject wrapper = doc.object(); const QJsonObject wrapper = doc.object();
const QJsonObject payload = wrapper.value("payload").toObject(); const QJsonObject payload = wrapper.value("payload").toObject();
@@ -121,61 +129,16 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate); const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate); const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
const bool identityOk = payload.value("app_id").toString() == expectedAppId
&& payload.value("device_id").toString() == expectedDeviceId
&& payload.value("version").toString() == expectedVersion;
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5) const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
&& expires >= now && issued.secsTo(expires) <= 65; && expires >= now && issued.secsTo(expires) <= 65;
if (actual.isEmpty() || actual != expected) { const QString nonce = payload.value("nonce").toString();
if (errorMessage) { if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk
*errorMessage = QCoreApplication::translate( || nonce.isEmpty()) {
"TicketHelper", if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
"Launch ticket signature is invalid. The launch_token used by Launcher and the main application is inconsistent, or the ticket content was changed.");
}
return false; return false;
} }
if (payload.value("app_id").toString() != expectedAppId) { return claimTicketNonce(expectedAppId, nonce, errorMessage);
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket app_id does not match. Ticket app_id: %1. Expected app_id: %2.")
.arg(payload.value("app_id").toString(), expectedAppId);
}
return false;
}
if (payload.value("device_id").toString() != expectedDeviceId) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket device_id does not match. Ticket device_id: %1. Expected device_id: %2. Reauthorize the device from Launcher if the configuration was regenerated.")
.arg(payload.value("device_id").toString(), expectedDeviceId);
}
return false;
}
if (payload.value("version").toString() != expectedVersion) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket version does not match. Ticket version: %1. Expected version: %2.")
.arg(payload.value("version").toString(), expectedVersion);
}
return false;
}
if (!timeOk) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket time is invalid or expired. Issued at: %1. Expires at: %2. Current UTC time: %3.")
.arg(payload.value("issued_at").toString(),
payload.value("expires_at").toString(),
now.toString(Qt::ISODate));
}
return false;
}
if (payload.value("nonce").toString().isEmpty()) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket nonce is missing. The ticket is incomplete.");
}
return false;
}
return true;
} }
+53
View File
@@ -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);
}
+18
View File
@@ -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;
};
@@ -1,81 +0,0 @@
客户端文档入口
==============
你第一次打开 update-client/Docs 时,先看这一份。这里告诉你每份文档是干什么的,以及不同角色应该从哪里开始。
文档阅读顺序
============
1. 01-客户端接入打包部署指南.md
适合 SDK 接入方、测试人员和交付人员。按“生成 SDK -> 放进业务软件 -> 生成配置 -> 联调 -> 打最终包”的顺序写。
2. 02-编译环境和第三方依赖说明.md
适合需要编译 Launcher、Updater、Bootstrap 的人。说明 Windows/Linux 下 Qt、OpenSSL、thirdparty/ 和 CMake 怎么准备。
3. ../i18n/ReadMe.txt
适合维护界面文案的人。说明新增 tr() 后怎么更新 .ts、生成 .qm,并把翻译文件打进 qrc。
常用任务入口
============
如果你只是拿到 SDK 接入业务软件:
```text
读 01-客户端接入打包部署指南.md 的“三、你:把 SDK 放进业务软件目录”和“四、你:生成并填写 app_config.json”。
```
如果你要重新打 Windows SDK 包:
```powershell
cd update-client
.\scripts\package-sdk.ps1 -SourceDir .\out\bin\Release -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
```
如果你要重新打 Linux SDK 包:
```bash
cd update-client
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
bash ./scripts/package-sdk.sh --source-dir ./out/linux/bin --output-dir ./dist/UpdateClientSDK-linux --archive ./dist/UpdateClientSDK-linux.tar.gz --sdk-version 0.1.0
```
客户端配置速记
==============
config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。后续运行配置优先从 QSettings 读取。
config/server_config.json 是编译期服务端地址配置源文件。它通过 config/server_config.qrc 编进 Launcher / Updater / MainApp,不写入 app_config.json,也不写入注册表。修改 api_base_url 后必须重新编译客户端程序才会生效。
如果 config/app_config.json 内容被修改,下一次启动时会按解析后的 JSON 内容 SHA256 判断变化并重新导入注册表。
非空 app_config.json 成功导入注册表后会自动清空为 {},文件保留不删除,方便下次直接粘贴管理后台生成的新配置。
Windows 注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。
Linux 配置位置由 Qt QSettings 决定,通常在当前用户 home 目录的 .config/Marsco/UpdateClientSDK.conf 一类路径下。
Windows 运行态数据目录类似:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\。
如果检测到 app_config.json 发生变化,SDK 会删除当前用户数据目录里的 client_identity.dat、version_policy.dat 和 local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
正常启动成功后不要删除这些状态文件,它们用于本地身份、离线策略和安全状态。
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
接入新软件时通常需要修改:
1. app_id、app_name、channel、current_version。
2. client_token、license_key、launch_token。
3. config/server_config.json 里的 api_base_url。
4. main_executable:团队业务主程序文件名。
5. launcher_executable、updater_executable、bootstrap_executable。
6. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
Windows 完整格式参考 update-client/config/app_config.example.jsonLinux 完整格式参考 update-client/config/app_config.linux.example.json。
运行时生成的 client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。app_config.json 可以作为部署模板,但不要把某台机器运行后产生的临时状态混进去。
Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验。签名 Manifest 缓存现在默认保存在当前用户数据目录:
Windows%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache
Linux$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache,未设置 XDG_DATA_HOME 时通常是 ~/.local/share。
打包脚本会优先从用户数据目录读取,也兼容旧版 Release 目录里的 update/manifest_cache。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名;确认客户端程序已用正确的 config/server_config.json 编译。
4. 在 PowerShell 执行:
powershell -ExecutionPolicy Bypass -File .\scripts\package-client.ps1 -ConfigFile .\config\app_config.json
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
@@ -1,432 +0,0 @@
# UpdateClientSDK 客户端接入、打包和部署指南
本文按“维护者打包 SDK -> 你接入业务软件 -> 联调测试 -> 生成最终客户端包”的顺序说明。你拿到这份文档后,按章节一步一步做即可。
## 先看这里:你要做哪件事
| 你的目标 | 直接看哪一节 |
| --- | --- |
| 重新生成给别人用的 SDK 包 | 二、维护者:生成 SDK 包 |
| 把 SDK 放到 SimCAE 或其他业务软件目录 | 三、你:把 SDK 放进业务软件目录 |
| 从后台生成 `app_config.json` 和 qrc 服务端配置 | 四、你:生成并填写客户端配置 |
| 给业务主程序接入启动保护代码 | 五、你:业务主程序接入要求 |
| 验证升级、回滚、健康检查 | 六、你:联调测试 |
| 生成最终交付给用户的客户端包 | 七、维护者:生成最终客户端包 |
## 一、这个 SDK 是什么
UpdateClientSDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力做成一组独立程序,让业务软件通过这些程序完成检查更新、下载、安装、回滚和启动保护。
SDK 核心程序:
- `Launcher.exe` / `Launcher`:用户入口。检查版本、验证授权和策略,决定直接启动业务主程序或进入升级流程。
- `Updater.exe` / `Updater`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe` / `Bootstrap`:处理运行中可能被占用的 EXE/DLL 或 Linux 可执行文件替换。
- `config/app_config.json`:部署配置源文件。启动时会同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。
- `config/server_config.json`:编译期服务端地址配置源文件,通过 `config/server_config.qrc` 编进 Launcher / Updater / MainApp,不写入 `app_config.json` 或注册表。
- `config/manifest_public_key.pem`:Manifest 签名公钥,用来验证服务端发布包没有被篡改。
## 二、维护者:生成 SDK 包
这一节是 SDK 维护者操作。接入方通常只需要拿到 `UpdateClientSDK.zip`
打包前确认:
1. 已在 Windows 上用 Release 配置编译完成,输出目录里有 `Launcher.exe``Updater.exe``Bootstrap.exe`
2. `config/manifest_public_key.pem` 和服务端使用的私钥是一对。
3. `update-client/Docs` 里的 Markdown 文档会随 SDK 一起打包,是默认接入说明来源。
4. Word 接入说明是可选增强。如果本地有文件名包含 `SDK``.docx`,打包脚本会额外复制到 SDK 根目录;如果没有,也不会影响 SDK 打包。
5. 如果业务软件本身已经带 Qt DLL,通常不要把 SDK 的 Qt 运行库打进去,避免 Qt 版本混用。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
生成结果:
```text
dist/
UpdateClientSDK/
sdk_manifest.json
Docs/
00-先读我-客户端文档入口.txt
01-客户端接入打包部署指南.md
02-编译环境和第三方依赖说明.md
bin/
Launcher.exe
Updater.exe
Bootstrap.exe
...
config/
app_config.json
manifest_public_key.pem
scripts/
install-sdk.ps1
package-client.ps1
package-sdk.ps1
SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现
UpdateClientSDK.zip
```
`dist/UpdateClientSDK.zip` 发给接入方即可。
可选参数:
- `-IncludeDemoMainApp`:把仓库里的 Demo 主程序 `MainApp.exe` 也打进 SDK,方便演示。
- `-IncludeQtRuntime`:把 Qt 运行库也打进 SDK。只有业务软件本身不带 Qt 时才建议使用。
Linux SDK 打包方式:
```bash
cd /home/laluo/project/update-client
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
bash ./scripts/package-sdk.sh \
--source-dir ./out/linux/bin \
--output-dir ./dist/UpdateClientSDK-linux \
--archive ./dist/UpdateClientSDK-linux.tar.gz \
--sdk-version 0.1.0
```
Linux SDK 包里核心程序名不带 `.exe`
```text
dist/
UpdateClientSDK-linux/
sdk_manifest.json
Docs/
00-先读我-客户端文档入口.txt
01-客户端接入打包部署指南.md
02-编译环境和第三方依赖说明.md
bin/
Launcher
Updater
Bootstrap
Common/
ConfigHelper.h
ConfigHelper.cpp
TicketHelper.h
TicketHelper.cpp
config/
app_config.json
manifest_public_key.pem
scripts/
package-sdk.sh
package-client.sh
SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现
UpdateClientSDK-linux.tar.gz
```
## 三、你:把 SDK 放进业务软件目录
假设业务软件目录是:
```text
D:\SimCAE\
bin\
SimCAE.exe
Qt5Core.dll
...
Licenses\
installerResources\
```
推荐把 SDK 放到 `bin` 目录,和 `SimCAE.exe` 同级;后台发布新版本时仍选择整个 `D:\SimCAE\` 作为发布根目录。
先解压 SDK
```powershell
Expand-Archive D:\交付\UpdateClientSDK.zip -DestinationPath D:\SimCAE_SDK -Force
```
再安装到业务软件的 `bin` 目录:
```powershell
cd D:\SimCAE\bin
D:\SimCAE_SDK\scripts\install-sdk.ps1 `
-SdkRoot D:\SimCAE_SDK `
-ReleaseDir .
```
安装后目录应类似:
```text
D:\SimCAE\
bin\
Launcher.exe
Updater.exe
Bootstrap.exe
SimCAE.exe
config\
app_config.json
manifest_public_key.pem
Qt5Core.dll
...
Licenses\
installerResources\
```
如果你需要重新覆盖 `config/app_config.json`,执行安装脚本时加 `-OverwriteConfig`
```powershell
D:\SimCAE_SDK\scripts\install-sdk.ps1 `
-SdkRoot D:\SimCAE_SDK `
-ReleaseDir D:\SimCAE\bin `
-OverwriteConfig
```
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应复用 SimCAE 的 `Qt5*.dll``platforms/``imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则可能出现“无法定位程序输入点”一类错误。
## 四、你:生成并填写客户端配置
最推荐的方式是在服务端管理后台生成客户端配置:
1. 浏览器打开服务端管理后台,例如 `http://服务器IP:8000/`
2. 登录后台。
3. 创建或选择应用,例如 `app_id=simcae`
4. 创建 License。
5. 在“客户端配置生成”区域选择应用、渠道、License 和主程序名。
6. 点击生成配置。
7. 复制“客户端 app_config.json”,覆盖 `D:\SimCAE\bin\config\app_config.json`
8. 复制“qrc 服务端配置 server_config.json”,覆盖 `update-client\config\server_config.json`,然后重新编译 Launcher / Updater / Bootstrap。这个文件会被 `config/server_config.qrc` 编进程序,不会放进用户机器的 `app_config.json` 或注册表。
配置同步规则:
- `app_config.json` 是部署配置源文件,适合交付、复制、人工修改。
- `api_base_url` 不再属于 `app_config.json` 字段。它只存在于 `config/server_config.json`,并通过 qrc 编进程序。
- 交付给你的 SDK 运行包不会包含 `server_config.json``server_config.qrc`。它们是编译材料,不是运行配置;修改 SDK 包里的文件不会改变已经编译好的 `Launcher.exe`
- Launcher / Updater / MainApp 启动时会计算 `app_config.json` 解析后的 JSON 内容 SHA256;如果 JSON 内容和上次导入时不同,就把文件里的配置重新写入当前 Windows 用户的注册表。
- 后续运行时优先从注册表读取配置,不再每次直接读 JSON。
- 运行过程中产生的动态值,例如首次输入的 `license_key`、服务端返回的 `device_id`、升级后的 `current_version`,会写入注册表。
- 为减少明文暴露,SDK 成功把非空 `app_config.json` 导入注册表后,会把 `app_config.json` 内容自动清空为 `{}`,但不会删除这个文件。以后你从管理后台复制新的客户端配置时,直接覆盖这个文件即可。
- SDK 会同步更新注册表里的 `source_sha256`,所以 `{}` 不会在下一次启动时反向覆盖注册表配置。
- 如果你手动修改了 `app_config.json`,下一次启动会重新导入并覆盖注册表里的同名字段。
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除当前用户数据目录里的 `client_identity.dat``version_policy.dat``local_state.json`,避免继续使用旧 License、旧设备身份、旧版本策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
- 正常启动成功后,不要删除 `client_identity.dat``version_policy.dat``local_state.json`。它们分别用于本地设备身份、离线策略和防回滚/防时间倒退,删除后会影响离线启动或导致重新授权。
- 注册表按安装目录隔离;同一台电脑上多个安装目录不会互相覆盖配置。
- 注册表位置为 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`
关键字段说明:
- `app_id`:服务端应用 ID,要和管理后台里的应用一致。
- `app_name`:应用显示名称。
- `channel`:发布渠道,例如 `stable``beta``dev`
- `current_version`:客户端当前初始版本,必须和后台已发布的版本一致。
- `client_protocol`:客户端协议号,当前建议为 `3`
- `launch_token`:本机启动票据 HMAC 密钥。服务端生成配置时会填默认值;正式部署建议按项目统一修改。
- `license_key`:管理后台创建 License 后生成的授权码。为空时首次启动 `Launcher.exe` 会弹窗让用户输入并保存到注册表;如果授权错误或过期,也会提示重新输入。
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,必须和服务端一致。
- `device_id`:设备 ID。一般可以留空,首次启动时 SDK 会向服务端登记并写入注册表。
- `install_root`:被更新的安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径。SDK 放在 `bin` 且主程序也在 `bin` 时填 `SimCAE.exe`
- `launcher_executable``updater_executable``bootstrap_executable`:通常不用改。
- `platform``arch`:当前为 `windows``x64`
典型配置:
```json
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "MARSCO-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
```
对应的 `config/server_config.json` 示例:
```json
{
"api_base_url": "http://192.168.229.128:8000"
}
```
## 五、你:业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
接入位置:
```text
main / WinMain 开头,创建主窗口之前
```
当前仓库里的 `update-client/MainApp/main.cpp` 是接入示例,已经实现:
- 启动票据校验。
- 本地 License/设备身份校验。
- 本地策略校验。
- Manifest 完整性校验。
- 健康标记写入。
真正接入业务软件时,把这些启动检查逻辑移植到业务主程序。用户入口应改成 `Launcher.exe`,不要让用户直接双击 `SimCAE.exe`
重要:业务主程序校验 ticket 时,必须使用 SDK 当前运行配置里的动态值,不要直接从 `app_config.json` 读取 `device_id`
原因是 `app_config.json` 是部署源文件,网页生成时 `device_id` 通常为空;真正的设备 ID 是 `Launcher.exe` 首次向服务端登记后写入当前用户注册表的。`Launcher.exe` 生成 ticket 时使用的是注册表里的真实 `device_id`。如果业务主程序从 `app_config.json` 读取空的 `device_id` 来校验,就会出现:
```text
ticket signature, identity, time or nonce invalid
```
或者细化后的:
```text
ticket device_id mismatch
```
业务主程序应像 `MainApp/main.cpp` 示例一样使用:
```cpp
ConfigHelper& config = ConfigHelper::instance();
TicketHelper::consumeAndVerify(
ticketFilePath,
config.getValue("App", "app_id"),
config.getValue("Update", "device_id"),
config.getValue("App", "current_version"),
config.getValue("App", "launch_token"),
&ticketError);
```
也就是说,接入业务主程序时不能只复制一小段 ticket 代码后自己解析 JSON;要么复用 SDK 的 `ConfigHelper` / `TicketHelper`,要么保证业务主程序读取到的 `app_id``device_id``current_version``launch_token``Launcher.exe` 完全来自同一套运行配置。
## 六、你:准备服务端数据
首次联调前,服务端至少要准备这些内容:
1. 创建应用,例如 `simcae`
2. 创建渠道,例如 `stable`
3. 创建 License。
4. 发布一个初始版本,例如 `1.0.0`
5. 生成客户端配置,并写入 `bin\config\app_config.json`。客户端下次启动时会自动同步到注册表。
6. 生成 qrc 服务端配置,并写入源码目录 `config\server_config.json` 后重新编译 SDK 程序。
为什么必须先发布初始版本:Launcher 启动业务主程序前会做 Manifest 完整性校验。这个 Manifest 是服务端发布版本时生成并签名的清单,用来证明当前本地文件属于一个可信版本。如果没有发布过 `current_version` 对应版本,客户端会提示签名 Manifest 缓存缺失。
后台发布版本时,选择整个安装根目录,例如 `D:\SimCAE\`,不要只选择 `D:\SimCAE\bin`。这样服务端会把 `bin/SimCAE.exe``Licenses/``installerResources/` 等完整结构写进 Manifest。
## 七、你:运行和联调
基础联调步骤:
1. 确认服务端正在运行。
2. 确认 `bin\config\app_config.json``client_token``license_key``current_version` 正确,并确认 `Launcher.exe` 已用正确的 `config/server_config.json` 重新编译。
3. 双击 `bin\Launcher.exe`
4. 首次启动时如果 `license_key` 为空,按弹窗输入后台创建的 License;SDK 会把它保存到注册表。
5. 成功进入业务主程序后,回到后台查看设备、升级日志、下载日志。
6. 在后台发布更高版本,例如从 `1.0.0` 发布到 `1.0.1`
7. 再次启动 `Launcher.exe`,验证升级、健康确认和回滚逻辑。
联调目录建议:
```text
D:\SimCAE_Release\
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
```
如果安装在 `C:\Program Files\...``D:\...` 或其他普通用户不一定可写的目录,SDK 的普通配置值会写入当前用户注册表,不需要修改 `app_config.json``client_identity.dat``local_state.json``version_policy.dat`、更新缓存和 Manifest 缓存也会写入当前用户数据目录,不再默认写到安装目录。
Windows 用户数据目录类似:`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\`。因此日常启动、首次授权、保存设备身份、保存策略、防回滚状态和下载缓存不应再触发管理员权限确认框。只有真正要替换安装目录里的 EXE/DLL 等程序文件时,才需要保证安装目录可写,或让安装器/Updater 具备相应权限。
## 八、维护者:生成某个产品的最终客户端包
SDK 是给接入方开发使用的。最终给用户安装或分发时,可以从已经联调过的 Release 目录生成最终客户端包。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-client.ps1 `
-SourceDir .\out\bin\Release `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
`package-client.ps1` 会检查:
- 配置文件必填字段是否完整。
- 主程序、Launcher、Updater、Bootstrap 是否存在。
- 是否混入 Debug DLL、PDB、ILK。
- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取:
`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache`
同时兼容旧版 Release 目录里的 `update\manifest_cache`
- 是否存在重复主程序。
生成结果:
```text
dist/
UpdateClient/
UpdateClient.zip
```
Linux 最终客户端包生成方式:
```bash
cd /home/laluo/project/update-client
bash ./scripts/package-client.sh \
--source-dir /path/to/SimCAE \
--config-file /path/to/SimCAE/bin/config/app_config.json \
--output-dir ./dist/UpdateClient-linux \
--archive ./dist/UpdateClient-linux.tar.gz
```
Linux 打包脚本会检查:
- `app_config.json` 必填字段是否完整。
- 主程序、Launcher、Updater、Bootstrap 是否存在。
- 是否混入 Debug 产物。
- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取:
`$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`
未设置 `XDG_DATA_HOME` 时通常是 `~/.local/share/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`
同时兼容旧版 Release 目录里的 `update/manifest_cache`
- 是否存在重复主程序。
Linux 下如果程序安装在 `/opt``/usr/local` 等普通用户不可写目录,升级器无法像 Windows UAC 那样自动提权修改安装目录。正式部署前建议二选一:
1. 把软件安装到当前用户有写权限的目录,例如用户 home 下的应用目录。
2. 由安装器创建专用目录和权限,让运行用户对软件目录有写入权限。
## 九、常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动保存配置/状态文件失败:如果目录不可写,SDK 会弹出管理员权限确认框;用户取消或当前账号没有管理员权限时仍会失败。
3. 首次启动设备登记失败:检查编译进 qrc 的 `config/server_config.json``client_token``license_key`、服务端 License 状态。
4. 提示 License 错误或过期:在后台确认 License 是否存在、是否被禁用或删除、是否超过最大设备数。
5. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
6. 提示 signed manifest cache missing:先在后台发布一次 `current_version` 对应版本,并让客户端拿到该版本 Manifest。
7. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
8. 发布失败提示主程序不在根目录:服务端 `.env``RELEASE_MAIN_EXECUTABLE` 要和平台匹配。Windows 通常是 `bin/SimCAE.exe`Linux 通常是 `bin/SimCAE`
9. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`
@@ -1,175 +0,0 @@
# 客户端编译环境和第三方依赖说明
`thirdparty/` 是本机依赖目录,已经被 `.gitignore` 忽略,不会提交到 Git。
当前客户端构建依赖:
1. Qt 5.15.2 或兼容的 Qt 5 版本
2. OpenSSL
Windows 下推荐使用 Qt 5.15.2 msvc2019_64 和 OpenSSL-Win64Linux 下使用系统安装的 Qt/OpenSSL 开发包。
先看结论:
- Windows:配置 Qt 环境变量,把 OpenSSL 复制到 `thirdparty/OpenSSL-Win64`
- Linux:用 apt 安装 Qt/OpenSSL 开发包。
- `thirdparty/` 只放本机依赖,不提交 Git。
## 1. Qt 配置
### Windows
Qt 路径由本机环境变量提供。你需要在 Windows 环境变量里配置 Qt 路径,让 CMake 的 `find_package(Qt5 ...)` 能找到 Qt。
推荐配置用户环境变量 `CMAKE_PREFIX_PATH`
```powershell
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
```
设置完成后,重新打开 PowerShell 或 Visual Studio。
如果只想对当前 PowerShell 窗口临时生效:
```powershell
$env:CMAKE_PREFIX_PATH="C:\Qt\5.15.2\msvc2019_64"
```
也可以配置更精确的 `Qt5_DIR`
```powershell
[Environment]::SetEnvironmentVariable("Qt5_DIR", "C:\Qt\5.15.2\msvc2019_64\lib\cmake\Qt5", "User")
```
`CMAKE_PREFIX_PATH``Qt5_DIR` 二选一即可,推荐使用 `CMAKE_PREFIX_PATH`
一般不需要把 `C:\Qt\5.15.2\msvc2019_64\bin` 加入 `Path`。项目构建后会通过 `windeployqt` 复制运行所需的 Qt DLL。
### Linux
Linux 下需要安装 Qt5 开发包,让 CMake 能找到 `Qt5::Core``Qt5::Network``Qt5::Gui``Qt5::Widgets`
Ubuntu/Debian 示例:
```bash
sudo apt update
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev-tools libssl-dev
```
如果 Qt 安装在自定义目录,可以临时设置:
```bash
export CMAKE_PREFIX_PATH=/path/to/Qt/5.x/gcc_64
```
## 2. OpenSSL 配置
### Windows
OpenSSL 默认放在:
```text
thirdparty/OpenSSL-Win64
```
推荐目录结构:
```text
thirdparty/
OpenSSL-Win64/
include/
openssl/
lib/
VC/
x64/
MD/
MDd/
```
复制命令示例:
```powershell
cd C:\Users\admin\Desktop\update-client
mkdir thirdparty
Copy-Item "C:\Program Files\OpenSSL-Win64" ".\thirdparty\OpenSSL-Win64" -Recurse
```
如果 OpenSSL 不放在 `thirdparty/`,可以在配置 CMake 时手动指定:
```powershell
cmake -S . -B out\build\x64-Debug `
-G "Visual Studio 17 2022" `
-A x64 `
-DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
```
### Linux
Linux 下 CMake 会通过 `find_package(OpenSSL REQUIRED)` 查找系统 OpenSSL。通常安装 `libssl-dev` 即可,不需要 `thirdparty/OpenSSL-Win64`
## 3. Windows 重新配置和编译
如果之前配置过 CMake,建议先删除旧缓存:
```powershell
cd C:\Users\admin\Desktop\update-client
Remove-Item out\build -Recurse -Force
```
重新配置:
```powershell
cmake -S . -B out\build\x64-Debug `
-G "Visual Studio 17 2022" `
-A x64
```
编译:
```powershell
cmake --build out\build\x64-Debug --config Debug
```
## 4. Linux 重新配置和编译
如果之前配置过 CMake,建议先删除旧缓存:
```bash
cd ~/project/update-client
rm -rf out/build/linux-x64-debug out/build/linux-x64-release
```
Debug 构建:
```bash
cmake --preset linux-x64-debug
cmake --build --preset linux-x64-debug
```
Release 构建:
```bash
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
```
Linux 构建时会自动使用 `config/app_config.linux.example.json` 作为输出目录里的默认 `config/app_config.json`,可执行程序名不带 `.exe`
## 5. 提交注意事项
不要提交下面这些内容:
```text
thirdparty/
.vs/
out/
build/
dist/
App/
*.exe
*.dll
*.lib
*.pdb
```
这些都属于本机依赖、构建产物或打包产物,不应该进 Git。
+29 -36
View File
@@ -1,42 +1,35 @@
cmake_minimum_required(VERSION 3.20) set(_launcher_sources
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)
# 所有源码文件
set(SRC_LIST
main.cpp main.cpp
UpdateLogic.h UpdateLogic.h
UpdateLogic.cpp UpdateLogic.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc )
${CMAKE_SOURCE_DIR}/config/server_config.qrc
) if(WIN32)
# 生成可执行程序 set(_launcher_manifest "${CMAKE_CURRENT_SOURCE_DIR}/launcher.manifest")
add_executable(Launcher ${SRC_LIST}) 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
UpdateClientUpdaterLogic
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) if(WIN32)
set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE)
endif() if(MSVC)
target_link_options(Launcher PRIVATE /MANIFESTUAC:NO)
# 关键:添加OpenSSL头文件目录 endif()
target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
# 链接Common,自动带上OpenSSL库
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
# 强制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() endif()
+1 -127
View File
@@ -1,10 +1,8 @@
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QDebug> #include <QDebug>
#include <QJsonArray> #include <QJsonArray>
#include <QApplication> #include <QApplication>
#include <QDir>
#include <QSaveFile>
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
@@ -101,130 +99,6 @@ void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, c
}); });
} }
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir)
{
m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
m_error = QCoreApplication::translate("UpdateLogic",
"Current version manifest identity is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.")
.arg(appId, channel, version, QString::number(versionId));
return false;
}
QJsonObject response;
int statusCode = 0;
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = version;
body["version_id"] = versionId;
m_http.postRequest(m_serverAddr + "/api/v1/update/manifest", body,
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
});
if (statusCode != 200) {
const QJsonValue detail = response.value(QStringLiteral("detail"));
const QString message = detail.isObject()
? detail.toObject().value(QStringLiteral("msg")).toString()
: detail.toString();
m_error = QCoreApplication::translate("UpdateLogic",
"Cannot download signed manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
.arg(QString::number(statusCode), appId, channel, version, QString::number(versionId),
message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
return false;
}
const QJsonObject manifest = response.value("manifest").toObject();
const QString manifestText = response.value("manifest_text").toString();
if (manifest.isEmpty() || manifestText.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic",
"The signed manifest response for the current local version is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.")
.arg(appId, channel, version, QString::number(versionId));
return false;
}
if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = QCoreApplication::translate("UpdateLogic",
"The signed manifest identity does not match the current local version. Stage: cache current version manifest. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate("UpdateLogic",
"Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.")
.arg(cacheDir);
return false;
}
QJsonObject wrapper;
wrapper["manifest"] = manifest;
wrapper["manifest_text"] = manifestText;
QSaveFile file(dir.filePath("manifest_" + version + ".json"));
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
m_error = QCoreApplication::translate("UpdateLogic",
"Cannot save signed manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
.arg(file.fileName(), file.errorString());
return false;
}
qDebug() << "Current version manifest cached to" << file.fileName();
return true;
}
bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
{
m_error.clear();
if (m_serverAddr.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic", "Server address is empty.");
return false;
}
QJsonObject response;
int statusCode = 0;
QJsonObject body;
body["app_id"] = m_appId;
body["channel"] = m_channel;
m_http.postRequest(m_serverAddr + "/api/v1/git/tags", body,
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
});
if (statusCode != 200) {
const QJsonValue detail = response.value("detail");
const QString message = detail.isObject()
? detail.toObject().value("msg").toString()
: detail.toString();
m_error = message.isEmpty()
? QCoreApplication::translate("UpdateLogic", "Git tags request failed (HTTP %1).").arg(statusCode)
: message;
return false;
}
const QString tagsText = response.value("tags_text").toString();
if (tagsText.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic", "Git tags response is empty.");
return false;
}
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(outputPath, tagsText.toUtf8(), &writeError)) {
m_error = QCoreApplication::translate("UpdateLogic", "Cannot write Git tags file: %1").arg(writeError);
return false;
}
qDebug() << "Git tags file refreshed:" << outputPath;
return true;
}
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
{ {
QString url = m_serverAddr + "/api/v1/update/report"; QString url = m_serverAddr + "/api/v1/update/report";
-5
View File
@@ -13,16 +13,12 @@ public:
void checkUpdate(); void checkUpdate();
void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId); void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId);
void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
bool cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir);
bool refreshGitTagsFile(const QString& outputPath);
bool getNeedUpdate() const { return m_needUpdate; } bool getNeedUpdate() const { return m_needUpdate; }
QString getLatestVersion() const { return m_latestVer; } QString getLatestVersion() const { return m_latestVer; }
QJsonObject getCheckResult() const { return m_checkResp; } QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; } bool isNetworkOk() const { return m_networkOk; }
int lastStatusCode() const { return m_lastStatusCode; } int lastStatusCode() const { return m_lastStatusCode; }
QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; } QString getAppId() const { return m_appId; }
QString getChannel() const { return m_channel; } QString getChannel() const { return m_channel; }
@@ -39,5 +35,4 @@ private:
int m_lastStatusCode = 0; int m_lastStatusCode = 0;
QString m_latestVer; QString m_latestVer;
QJsonObject m_checkResp; QJsonObject m_checkResp;
QString m_error;
}; };
+10
View File
@@ -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>
+232 -198
View File
@@ -1,33 +1,31 @@
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QInputDialog> #include <QInputDialog>
#include <QLineEdit> #include <QLineEdit>
#include <QTranslator>
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h" #include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h" #include "../Common/DeviceIdentityHelper.h"
#include "../Common/IntegrityHelper.h"
#include "../Common/ManifestBootstrapPolicy.h"
#include "../Common/TranslationHelper.h"
#include "../Updater/UpdaterLogic.h"
#include <QFile> #include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QDir> #include <QDir>
int main(int argc, char* argv[]) int main(int argc, char *argv[])
{ {
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher"); QApplication::setApplicationName("Marsco Launcher");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); TranslationHelper translation;
if (elevatedWriteExitCode >= 0) translation.installForSystemLocale();
return elevatedWriteExitCode;
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0); QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher")); progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher"));
@@ -39,91 +37,117 @@ int main(int argc, char* argv[])
QApplication::processEvents(); QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath(); const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper &config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) { const auto isLicenseError = [&translation](const QString &errorText)
{
const QString text = errorText.toLower(); const QString text = errorText.toLower();
return text.contains("license") QStringList markers{QStringLiteral("license")};
|| text.contains("authorization") const char *const markerSources[]{
|| text.contains("expired") QT_TRANSLATE_NOOP("LauncherErrorMarker", "authorization"),
|| text.contains("device limit") QT_TRANSLATE_NOOP("LauncherErrorMarker", "expired"),
|| text.contains("bound to another license"); 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 = [&]()
ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath()); {
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
config.setValue("Update", "device_id", QString()); config.setValue("Update", "device_id", QString());
}; };
QString licenseKey = config.getValue("License", "license_key").trimmed(); QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString& reason) -> QString { auto promptAndSaveLicense = [&](const QString &reason) -> QString
{
QString promptReason = reason; QString promptReason = reason;
while (true) { while (true)
{
progress.close(); progress.close();
bool accepted = false; bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed(); 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() const QString prompt = promptReason.trimmed().isEmpty()
? QCoreApplication::translate("Launcher", "Please enter the License for %1:") ? QCoreApplication::translate("Launcher", "Enter the license for %1:").arg(displayName)
.arg(appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName) : QCoreApplication::translate("Launcher", "%1\n\nPlease re-enter the license for %2:").arg(promptReason, displayName);
: QCoreApplication::translate("Launcher", "%1\n\nPlease enter a new License for %2:")
.arg(promptReason, appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName);
licenseKey = QInputDialog::getText( licenseKey = QInputDialog::getText(
nullptr, nullptr,
QCoreApplication::translate("Launcher", "Enter License"), QCoreApplication::translate("Launcher", "Enter License"),
prompt, prompt,
QLineEdit::Normal, QLineEdit::Normal,
QString(), QString(),
&accepted &accepted)
).trimmed(); .trimmed();
if (!accepted) { if (!accepted)
QMessageBox::information(nullptr, {
QMessageBox::information(
nullptr,
QCoreApplication::translate("Launcher", "License Required"), QCoreApplication::translate("Launcher", "License Required"),
QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch.")); QCoreApplication::translate("Launcher", "A license provided by an administrator is required for the first launch."));
return QString(); return QString();
} }
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty())
QMessageBox::warning(nullptr, {
QMessageBox::warning(
nullptr,
QCoreApplication::translate("Launcher", "License Cannot Be Empty"), QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
QCoreApplication::translate("Launcher", "Please paste the License created in the admin page.")); QCoreApplication::translate("Launcher", "Paste the license created by an administrator."));
promptReason.clear(); promptReason.clear();
continue; continue;
} }
if (!config.setValue("License", "license_key", licenseKey)) { if (!config.setValue("License", "license_key", licenseKey))
QMessageBox::critical(nullptr, {
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Failed to Save License"), QCoreApplication::translate("Launcher", "Failed to Save License"),
QCoreApplication::translate("Launcher", "Cannot write the configuration file:\n%1\n%2").arg(config.configPath(), config.lastError())); QCoreApplication::translate("Launcher", "Cannot save system state at %1:\n%2")
.arg(config.configPath(), config.lastError()));
return QString(); return QString();
} }
progress.show(); progress.show();
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License...")); progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying license..."));
QApplication::processEvents(); QApplication::processEvents();
return licenseKey; return licenseKey;
} }
}; };
while (true) { while (true)
// License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。 {
// 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。 if (licenseKey.isEmpty())
if (licenseKey.isEmpty()) { {
licenseKey = promptAndSaveLicense(QString()); licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty())
return 0;
} }
DeviceIdentityHelper identity(appDir); DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"), if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"), config.getValue("Server", "client_token"),
config.getValue("App", "app_id"), config.getValue("App", "app_id"),
config.getValue("App", "channel"), config.getValue("App", "channel"),
licenseKey)) { licenseKey))
{
break; break;
} }
const QString error = identity.errorString(); const QString error = identity.errorString();
if (!isLicenseError(error)) { if (!isLicenseError(error))
{
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Device Authentication Failed"), error);
QCoreApplication::translate("Launcher", "Device Authentication Failed"),
error);
return -1; return -1;
} }
clearDeviceCredential(); clearDeviceCredential();
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error)); licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current license cannot be used: %1").arg(error));
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty())
return 0;
} }
UpdateLogic logic; UpdateLogic logic;
@@ -136,139 +160,177 @@ int main(int argc, char* argv[])
const int targetVersionId = response.value("version_id").toInt(); const int targetVersionId = response.value("version_id").toInt();
const QString appId = logic.getAppId(); const QString appId = logic.getAppId();
const QString channel = logic.getChannel(); const QString channel = logic.getChannel();
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403)
{
progress.close(); progress.close();
const QJsonValue detail = response.value("detail"); const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty() const QString displayMessage = message.isEmpty()
? QCoreApplication::translate("Launcher", "The device or License authorization is invalid.") ? QCoreApplication::translate("Launcher", "The device or license authorization is invalid.")
: message; : message;
if (isLicenseError(displayMessage)) { if (isLicenseError(displayMessage))
{
clearDeviceCredential(); clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current authorization was rejected by the server: %1").arg(displayMessage)); const QString newLicense = promptAndSaveLicense(
if (!newLicense.isEmpty()) { QCoreApplication::translate("Launcher", "The server rejected the current license: %1").arg(displayMessage));
if (!newLicense.isEmpty())
{
progress.close(); progress.close();
QMessageBox::information(nullptr, QMessageBox::information(
nullptr,
QCoreApplication::translate("Launcher", "License Saved"), QCoreApplication::translate("Launcher", "License Saved"),
QCoreApplication::translate("Launcher", "Please restart Launcher to complete device authorization and update checking.")); QCoreApplication::translate("Launcher", "Restart Launcher to complete device authorization and check for updates."));
} }
return 0; return 0;
} }
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Authorization Denied"), displayMessage);
QCoreApplication::translate("Launcher", "Authorization Rejected"),
displayMessage);
return -1; return -1;
} }
const auto configuredName = [&](const QString& key, const QString& fallback) { const QString launchToken = config.getValue("App", "launch_token");
return ConfigHelper::executableNameForCurrentPlatform( const QString currentVersion = config.getValue("App", "current_version");
config.getValue("Runtime", key), fallback); const auto configuredName = [&](const QString &key, const QString &fallback)
{
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp"); const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater"); const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString mainAppPath = QDir(appDir).filePath(mainExecutable); const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable); const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
const auto importOfflinePackage = [&]() { IntegrityHelper integrity(config.installRoot());
const QString package = QFileDialog::getOpenFileName(nullptr, bool integrityVerified = integrity.verifyInstalledVersion(
appId, channel, currentVersion);
QString integrityError = integrity.errorString();
const UpdateClient::ManifestBootstrapAction bootstrapAction =
UpdateClient::manifestBootstrapAction(
integrity.isManifestCacheMissing(), networkOk, needUpdate,
targetVersionId);
if (!integrityVerified
&& bootstrapAction
== UpdateClient::ManifestBootstrapAction::FetchCurrentManifest)
{
UpdaterLogic manifestLogic;
manifestLogic.getManifest(
appId, channel, currentVersion, targetVersionId);
const QString cacheDirectory = QDir(config.updateRoot()).filePath(
QStringLiteral("manifest_cache"));
if (manifestLogic.verifyManifestSignature()
&& manifestLogic.saveManifestCache(cacheDirectory))
{
integrityVerified = integrity.verifyInstalledVersion(
appId, channel, currentVersion);
integrityError = integrity.errorString();
}
else
{
integrityError = QCoreApplication::translate(
"Launcher",
"The signed manifest for version %1 could not be fetched or cached.")
.arg(currentVersion);
}
}
else if (!integrityVerified
&& bootstrapAction
== UpdateClient::ManifestBootstrapAction::CurrentVersionNotPublished)
{
integrityError = QCoreApplication::translate(
"Launcher",
"Version %1 is not published on the update server. Publish this exact installed build before distribution so its signed manifest can be verified.")
.arg(currentVersion);
}
if (!integrityVerified)
{
progress.close();
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Integrity Check Failed"),
QCoreApplication::translate(
"Launcher",
"Application files failed signed manifest verification:\n%1")
.arg(integrityError));
return -1;
}
const auto importOfflinePackage = [&]()
{
const QString package = QFileDialog::getOpenFileName(
nullptr,
QCoreApplication::translate("Launcher", "Select Offline Update Package"), QCoreApplication::translate("Launcher", "Select Offline Update Package"),
QString(), QString(),
QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)")); QCoreApplication::translate("Launcher", "Marsco Offline Update Package (*.upd)"));
if (package.isEmpty()) return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
return false;
if (!QFileInfo::exists(updaterPath)) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot import the offline update package because the updater executable does not exist.\nUpdater: %1\nOffline package: %2")
.arg(updaterPath, package));
return false;
}
if (!QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)})) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater for offline package import.\nUpdater: %1\nOffline package: %2\nCheck file permissions and dependent DLLs/shared libraries.")
.arg(updaterPath, package));
return false;
}
return true;
}; };
const QString deviceId = config.getValue("Update", "device_id"); const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) { if (QCoreApplication::arguments().contains("--import-offline"))
{
progress.close(); progress.close();
if (!importOfflinePackage()) if (!importOfflinePackage())
QMessageBox::information(nullptr, QMessageBox::information(
nullptr,
QCoreApplication::translate("Launcher", "Offline Update"), QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected.")); QCoreApplication::translate("Launcher", "No offline update package was selected."));
return 0; return 0;
} }
QString mainStartupError; const auto startMainApp = [&]()
const auto startMainApp = [&]() { {
// 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。
// 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) { launchToken, &ticketPath, &ticketError))
{
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false; return false;
} }
const bool started = QProcess::startDetached(mainAppPath, const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)}); QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) { if (!started)
QFile::remove(ticketPath); QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath);
}
return started; return started;
}; };
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy...")); progress.setLabelText(QCoreApplication::translate("Launcher", "Validating the local run policy..."));
QApplication::processEvents(); QApplication::processEvents();
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
QCoreApplication::translate("Launcher", "Cannot Start"), 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())); QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
return -1; return -1;
} }
if (policy.isExpired()) if (policy.isExpired())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
QCoreApplication::translate("Launcher", "Cannot Start"), nullptr,
QCoreApplication::translate("Launcher", "The local version policy has expired. Please connect to the network or contact the administrator.")); 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; return -1;
} }
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) const bool currentVersionAllowed =
policy.allowRun() && policy.isVersionAllowed(currentVersion);
if (!currentVersionAllowed && !(networkOk && needUpdate))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Current Version Cannot Run"), QCoreApplication::translate("Launcher", "Current Version Cannot Run"),
policy.message().isEmpty() ? QCoreApplication::translate("Launcher", "The current version %1 has been disabled by the administrator.").arg(currentVersion) policy.message().isEmpty()
? QCoreApplication::translate("Launcher", "Version %1 has been disabled by an administrator.").arg(currentVersion)
: policy.message()); : policy.message());
return -1; return -1;
} }
@@ -277,120 +339,92 @@ int main(int argc, char* argv[])
if (!state.loadState()) if (!state.loadState())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
QCoreApplication::translate("Launcher", "Cannot Start"), nullptr,
QCoreApplication::translate("Launcher", "Unable to Start"),
QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString())); QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq())) if (state.isPolicySeqRolledBack(policy.policySeq()))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"), QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected. Startup has been blocked.")); QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected, so startup was blocked."));
return -1; return -1;
} }
if (state.isSystemTimeRewound()) if (state.isSystemTimeRewound())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"), QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A possible system time rollback was detected. Startup has been blocked.")); QCoreApplication::translate("Launcher", "A possible system clock rollback was detected, so startup was blocked."));
return -1; return -1;
} }
if (networkOk && policy.gitTagsEnabled())
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Generating Git tag list..."));
QApplication::processEvents();
const QString tagsPath = QDir(appDir).filePath("tags.txt");
if (!logic.refreshGitTagsFile(tagsPath))
{
progress.close();
QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "Git Tag List Failed"),
QCoreApplication::translate("Launcher", "Cannot generate tags.txt, but startup will continue:\n%1").arg(logic.errorString()));
progress.show();
QApplication::processEvents();
}
}
if (networkOk && needUpdate) if (networkOk && needUpdate)
{ {
progress.close(); progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback") const bool updateRequired = policy.forceUpdate() || !currentVersionAllowed;
: (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available")); 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() const QString prompt = policy.message().isEmpty()
? (rollbackOperation ? (rollbackOperation
? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer) ? 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)) : QCoreApplication::translate("Launcher", "Version %1 is available. Update now?"))
.arg(latestVer)
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer); : policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
bool accepted = true; bool accepted = true;
if (policy.forceUpdate()) { if (updateRequired)
{
QMessageBox::information(nullptr, dialogTitle, prompt); QMessageBox::information(nullptr, dialogTitle, prompt);
} else { }
else
{
accepted = QMessageBox::question(nullptr, dialogTitle, prompt, accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
} }
if (!accepted) { if (!accepted)
if (!startMainApp()) { {
QMessageBox::critical(nullptr, if (!startMainApp())
{
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"), QCoreApplication::translate("Launcher", "Startup Failed"),
mainStartupError.isEmpty() QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1; return -1;
} }
return 0; return 0;
} }
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
if (!QFileInfo::exists(updaterPath))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater because the executable file does not exist.\nExecutable: %1\nCheck updater_executable and install_root in the generated client configuration.")
.arg(updaterPath));
return -1;
}
if (!QProcess::startDetached(updaterPath, updaterArgs)) if (!QProcess::startDetached(updaterPath, updaterArgs))
{ {
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"), QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate( QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath));
"Launcher",
"Cannot start the updater process.\nExecutable: %1\nArguments: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the updater can run independently.")
.arg(updaterPath, updaterArgs.join(QStringLiteral(" "))));
return -1; return -1;
} }
return 0; return 0;
} }
if (networkOk && !needUpdate && targetVersionId > 0 && latestVer == currentVersion) if (!networkOk)
{ {
progress.setLabelText(QCoreApplication::translate("Launcher", "Caching signed version manifest..."));
QApplication::processEvents();
const QString manifestCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("manifest_cache");
if (!logic.cacheManifest(appId, channel, currentVersion, targetVersionId, manifestCacheDir))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Manifest Cache Failed"),
QCoreApplication::translate("Launcher", "Cannot cache the signed manifest for the current version: %1").arg(logic.errorString()));
return -1;
}
}
if (!networkOk) {
progress.close(); progress.close();
if (QMessageBox::question(nullptr, if (QMessageBox::question(nullptr,
QCoreApplication::translate("Launcher", "Server Unavailable"), QCoreApplication::translate("Launcher", "Server Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server. Import an offline update package?"), QCoreApplication::translate("Launcher", "The update server is currently unreachable. Import an offline update package?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes)
{
if (!importOfflinePackage()) if (!importOfflinePackage())
QMessageBox::information(nullptr, QMessageBox::information(
nullptr,
QCoreApplication::translate("Launcher", "Offline Update"), QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started.")); QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started."));
return 0; return 0;
@@ -401,24 +435,24 @@ int main(int argc, char* argv[])
if (!networkOk && !policy.isOfflineAllowed()) if (!networkOk && !policy.isOfflineAllowed())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Network Unavailable"), QCoreApplication::translate("Launcher", "Network Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server, and the current policy does not allow offline startup.")); QCoreApplication::translate("Launcher", "The update server is unreachable and the current policy does not allow offline startup."));
return -1; return -1;
} }
progress.setLabelText(networkOk progress.setLabelText(
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...") networkOk ? QCoreApplication::translate("Launcher", "The current version is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting...")); : QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
QApplication::processEvents(); QApplication::processEvents();
if (!startMainApp()) if (!startMainApp())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"), QCoreApplication::translate("Launcher", "Startup Failed"),
mainStartupError.isEmpty() QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1; return -1;
} }
progress.close(); progress.close();
+13 -18
View File
@@ -1,23 +1,18 @@
project(MainApp) set(_main_app_sources
add_executable(MainApp
main.cpp main.cpp
MainWindow.h MainWindow.h
MainWindow.cpp MainWindow.cpp
../Common/ConfigHelper.h
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
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) add_executable(MainApp ${_main_app_sources})
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) target_compile_features(MainApp PRIVATE cxx_std_17)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) target_link_libraries(MainApp
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") PRIVATE
add_custom_command(TARGET MainApp POST_BUILD Common
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp> Qt5::Core
) Qt5::Gui
endif() Qt5::Widgets
)
update_client_link_embedded_resources(MainApp)
update_client_deploy_openssl_runtime(MainApp)
update_client_enable_qt_deployment(MainApp)
+10 -10
View File
@@ -12,10 +12,10 @@ static QString readDllVersion(const QString& dllPath)
{ {
QFile file(dllPath); QFile file(dllPath);
if (!file.exists()) if (!file.exists())
return "missing"; return QCoreApplication::translate("MainWindow", "missing");
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
return "unreadable"; return QCoreApplication::translate("MainWindow", "unreadable");
QByteArray data = file.read(1024); QByteArray data = file.read(1024);
file.close(); file.close();
@@ -25,12 +25,12 @@ static QString readDllVersion(const QString& dllPath)
MainWindow::MainWindow(QWidget* parent) MainWindow::MainWindow(QWidget* parent)
: QWidget(parent) : QWidget(parent)
{ {
this->setWindowTitle("Marsco Demo MainApp"); this->setWindowTitle(tr("Marsco Demo MainApp"));
this->resize(600, 400); this->resize(600, 400);
QString appVersion = ConfigHelper::instance().getValue("App", "current_version"); QString appVersion = ConfigHelper::instance().getValue("App", "current_version");
if (appVersion.isEmpty()) if (appVersion.isEmpty())
appVersion = "unknown"; appVersion = tr("unknown");
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
@@ -38,27 +38,27 @@ MainWindow::MainWindow(QWidget* parent)
QString policyResult; QString policyResult;
if (!policy.loadPolicy("config/version_policy.dat")) if (!policy.loadPolicy("config/version_policy.dat"))
{ {
policyResult = "policy missing"; policyResult = tr("policy missing");
} }
else if (!policy.isValid()) else if (!policy.isValid())
{ {
policyResult = "policy invalid"; policyResult = tr("policy invalid");
} }
else if (!policy.isVersionAllowed(appVersion)) else if (!policy.isVersionAllowed(appVersion))
{ {
policyResult = "version disabled"; policyResult = tr("version disabled");
} }
else if (policy.isExpired()) else if (policy.isExpired())
{ {
policyResult = "policy expired"; policyResult = tr("policy expired");
} }
else else
{ {
policyResult = "policy ok"; policyResult = tr("policy ok");
} }
QVBoxLayout* layout = new QVBoxLayout(this); QVBoxLayout* layout = new QVBoxLayout(this);
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3") QLabel* label = new QLabel(tr("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
.arg(appVersion) .arg(appVersion)
.arg(dllVersion) .arg(dllVersion)
.arg(policyResult)); .arg(policyResult));
+52 -25
View File
@@ -1,10 +1,9 @@
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QMessageBox> #include <QMessageBox>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QSaveFile> #include <QSaveFile>
#include <QTranslator>
#include "MainWindow.h" #include "MainWindow.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
@@ -12,23 +11,20 @@
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h" #include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h" #include "../Common/DeviceIdentityHelper.h"
#include "../Common/TranslationHelper.h"
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
QApplication a(argc, argv); QApplication a(argc, argv);
QTranslator translator; QApplication::setApplicationName("Marsco MainApp");
if (translator.load(":/i18n/update-client_zh_CN.qm")) TranslationHelper translation;
a.installTranslator(&translator); translation.installForSystemLocale();
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
qDebug() << Qt::endl << "entered main app" << Qt::endl; qDebug() << Qt::endl << "entered main app" << Qt::endl;
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher"); if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
QString ticketFilePath; QString ticketFilePath;
QString healthFilePath; QString healthFilePath;
for (int i = 1; i < argc; ++i) for (int i = 1; i < argc; ++i)
@@ -47,8 +43,9 @@ int main(int argc, char* argv[])
config.getValue("App", "current_version"), config.getValue("App", "launch_token"), config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
&ticketError)) &ticketError))
{ {
QMessageBox::critical(nullptr, "Startup Restriction", QMessageBox::critical(nullptr,
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") QCoreApplication::translate("MainApp", "Startup Restriction"),
QCoreApplication::translate("MainApp", "Invalid or missing one-time launch ticket: %1\nPlease use %2.")
.arg(ticketError, launcherExecutable)); .arg(ticketError, launcherExecutable));
return -1; return -1;
} }
@@ -58,50 +55,76 @@ int main(int argc, char* argv[])
DeviceIdentityHelper identity(appDir); DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) 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; return -1;
} }
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) 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; return -1;
} }
if (policy.isExpired()) 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; return -1;
} }
LocalStateHelper state(appDir); LocalStateHelper state(appDir);
if (!state.loadState()) 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; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq())) 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; return -1;
} }
if (state.isSystemTimeRewound()) 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; return -1;
} }
IntegrityHelper integrity(installRoot); IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion( if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"), config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version"))) currentVersion))
{ {
QMessageBox::critical(nullptr, "Integrity Check Failed", QMessageBox::critical(nullptr,
QString("Startup blocked because the installed files failed local signed Manifest verification.\n" QCoreApplication::translate("MainApp", "Integrity Check Failed"),
"This is not a download check; it means the current installation directory does not match the signed Manifest cache for this version.\n\n" QCoreApplication::translate("MainApp", "Application files failed signed manifest verification:\n%1")
"Details:\n%1")
.arg(integrity.errorString())); .arg(integrity.errorString()));
return -1; return -1;
} }
@@ -112,7 +135,9 @@ int main(int argc, char* argv[])
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq()); state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState()) 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; return -1;
} }
@@ -125,7 +150,9 @@ int main(int argc, char* argv[])
|| healthFile.write(healthy) != healthy.size() || healthFile.write(healthy) != healthy.size()
|| !healthFile.commit()) || !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; return -1;
} }
} }
+78
View File
@@ -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/ 保持的架构和操作文档
```
+17 -35
View File
@@ -1,42 +1,24 @@
if(WIN32 AND MSVC) set(_updater_sources
# 强制所有编译、设计时生成均使用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)
endif()
project(Updater)
set(SRC
main.cpp main.cpp
UpdaterLogic.h
UpdaterLogic.cpp
UpdateTransaction.h UpdateTransaction.h
UpdateTransaction.cpp UpdateTransaction.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc )
${CMAKE_SOURCE_DIR}/config/server_config.qrc
) add_executable(Updater ${_updater_sources})
add_executable(Updater ${SRC}) target_compile_features(Updater PRIVATE cxx_std_17)
target_link_libraries(Updater
PRIVATE
Common
UpdateClientUpdaterLogic
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) if(WIN32)
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
endif() endif()
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
# 仅链接Common和QtOpenSSL库由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()
-7
View File
@@ -37,8 +37,6 @@ bool UpdateTransaction::copyOverwrite(const QString& source, const QString& dest
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message) bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
{ {
// upgrade_state.json 是升级事务的“黑匣子”。
// 如果替换文件时断电或崩溃,Bootstrap/Updater 会根据这里的状态继续提交或回滚。
m_state["transaction_id"] = m_transactionId; m_state["transaction_id"] = m_transactionId;
m_state["from_version"] = m_fromVersion; m_state["from_version"] = m_fromVersion;
m_state["to_version"] = m_toVersion; m_state["to_version"] = m_toVersion;
@@ -150,8 +148,6 @@ bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
bool UpdateTransaction::backupCurrentFiles() bool UpdateTransaction::backupCurrentFiles()
{ {
// 替换前先备份所有将被修改或删除的文件。
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
if (!writeState("waiting_mainapp_exit")) return false; if (!writeState("waiting_mainapp_exit")) return false;
QStringList paths = m_changedPaths; QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths); paths.append(m_obsoletePaths);
@@ -167,8 +163,6 @@ bool UpdateTransaction::backupCurrentFiles()
bool UpdateTransaction::installStagedFiles(QString* failedPath) bool UpdateTransaction::installStagedFiles(QString* failedPath)
{ {
// staging 目录里只放已经下载并校验过 hash 的新文件。
// 真正覆盖安装目录时如果任意一个文件失败,就进入 rollback_required。
if (!writeState("replacing")) return false; if (!writeState("replacing")) return false;
for (const QString& path : m_changedPaths) { for (const QString& path : m_changedPaths) {
const QString source = QDir(m_stagingDir).filePath(path); const QString source = QDir(m_stagingDir).filePath(path);
@@ -232,7 +226,6 @@ QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePat
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir, bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion, QString* errorMessage) QString* restoredVersion, QString* errorMessage)
{ {
// 启动时恢复未完成事务:如果上次升级停在替换/验证/回滚中间,优先恢复到可启动状态。
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir) QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
.filePath("upgrade_state.json"); .filePath("upgrade_state.json");
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json"); const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
+326 -272
View File
@@ -3,7 +3,6 @@
#include <QDebug> #include <QDebug>
#include <QFileInfo> #include <QFileInfo>
#include <QFile> #include <QFile>
#include <QCoreApplication>
#include <QEventLoop> #include <QEventLoop>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QThread> #include <QThread>
@@ -15,8 +14,12 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QUrl>
#include <algorithm> #include <algorithm>
#include <cmath>
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "RuntimeProtectionPolicy.h"
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/pem.h> #include <openssl/pem.h>
@@ -25,32 +28,54 @@
#include <openssl/err.h> #include <openssl/err.h>
#endif #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) UpdaterLogic::UpdaterLogic(QObject* parent)
: QObject(parent) : QObject(parent)
{ {
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url"); m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
} }
namespace {
QString serverDetailMessage(const QJsonObject& response)
{
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject obj = detail.toObject();
const QString msg = obj.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty()) return msg;
const QString error = obj.value(QStringLiteral("error")).toString();
if (!error.isEmpty()) return error;
}
return detail.toString();
}
}
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId) void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{ {
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。 m_manifest = QJsonObject();
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。 m_manifestText.clear();
m_error.clear(); m_fileItems.clear();
m_manifestSignatureVerified = false;
QString url = m_serverAddr + "/api/v1/update/manifest"; QString url = m_serverAddr + "/api/v1/update/manifest";
QJsonObject body; QJsonObject body;
body["app_id"] = appId; body["app_id"] = appId;
@@ -63,48 +88,139 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
qDebug() << "Manifest API returned code:" << code; qDebug() << "Manifest API returned code:" << code;
m_manifest = QJsonObject(); m_manifest = QJsonObject();
m_manifestText.clear(); m_manifestText.clear();
m_fileItems.clear();
m_manifestSignatureVerified = false;
if (code == 200) 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(); qDebug() << "Manifest response fields have invalid types";
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);
}
} }
else else
{ {
qDebug() << "Manifest response missing fields"; const QString manifestText = manifestTextValue.toString();
m_error = QCoreApplication::translate("UpdaterLogic", const QJsonObject responseManifest = responseManifestValue.toObject();
"Target version manifest response is incomplete. Stage: download target manifest. App: %1, channel: %2, version: %3, version id: %4.") const QJsonValue signatureValue = responseManifest.value("signature");
.arg(appId, channel, targetVer, QString::number(versionId)); 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 else
{ {
qDebug() << "Failed to get manifest"; qDebug() << "Failed to get manifest";
const QString detail = serverDetailMessage(resp);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
.arg(QString::number(code), appId, channel, targetVer, QString::number(versionId),
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
} }
emit fetchUrlFinished(); emit fetchUrlFinished();
}); });
} }
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 bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
@@ -112,17 +228,12 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
Q_UNUSED(signatureBase64); Q_UNUSED(signatureBase64);
Q_UNUSED(publicKeyPath); Q_UNUSED(publicKeyPath);
qDebug() << "OpenSSL not available, cannot verify manifest signature"; qDebug() << "OpenSSL not available, cannot verify manifest signature";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot verify manifest signature because OpenSSL support is unavailable. Stage: manifest signature verification.");
return false; return false;
#else #else
QFile keyFile(publicKeyPath); QFile keyFile(publicKeyPath);
if (!keyFile.open(QIODevice::ReadOnly)) if (!keyFile.open(QIODevice::ReadOnly))
{ {
qDebug() << "Cannot open public key file:" << publicKeyPath; qDebug() << "Cannot open public key file:" << publicKeyPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open manifest public key. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false; return false;
} }
@@ -133,9 +244,6 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!bio) if (!bio)
{ {
qDebug() << "BIO_new_mem_buf failed"; qDebug() << "BIO_new_mem_buf failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot parse manifest public key buffer. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false; return false;
} }
@@ -144,9 +252,6 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!pkey) if (!pkey)
{ {
qDebug() << "PEM_read_bio_PUBKEY failed"; qDebug() << "PEM_read_bio_PUBKEY failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest public key is invalid. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false; return false;
} }
@@ -156,8 +261,6 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
{ {
EVP_PKEY_free(pkey); EVP_PKEY_free(pkey);
qDebug() << "EVP_MD_CTX_new failed"; qDebug() << "EVP_MD_CTX_new failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create OpenSSL verification context. Stage: manifest signature verification.");
return false; return false;
} }
@@ -179,8 +282,6 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!ok) if (!ok)
{ {
qDebug() << "Manifest signature verification failed"; qDebug() << "Manifest signature verification failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest RSA signature is invalid. Stage: manifest signature verification. This usually means the manifest was not signed by the matching server private key, the client public key is wrong, or the manifest content was changed.");
} }
return ok; return ok;
#endif #endif
@@ -188,59 +289,37 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
{ {
// 验签用的是客户端随 SDK 分发的公钥。 m_manifestSignatureVerified = false;
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
if (m_manifest.isEmpty() || m_manifestText.isEmpty()) if (m_manifest.isEmpty() || m_manifestText.isEmpty())
{ {
qDebug() << "No manifest available to verify"; qDebug() << "No manifest available to verify";
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
return false; return false;
} }
QString signature = m_manifest.value("signature").toString(); QString signature = m_manifest.value("signature").toString();
if (signature.isEmpty()) if (signature.isEmpty())
{ {
qDebug() << "Manifest signature empty"; qDebug() << "Manifest signature empty";
m_error = QCoreApplication::translate("UpdaterLogic",
"The manifest does not contain a signature. Stage: manifest signature verification.");
return false; return false;
} }
QString path = publicKeyPath; m_manifestSignatureVerified = verifySignature(m_manifestText.toUtf8(), signature, publicKeyPath);
if (!QFile::exists(path)) return m_manifestSignatureVerified;
{
path = QApplication::applicationDirPath() + "/" + publicKeyPath;
}
return verifySignature(m_manifestText.toUtf8(), signature, path);
} }
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
{ {
// 启动后的完整性检查依赖本地 Manifest 缓存。 if (m_manifest.isEmpty() || !m_manifestSignatureVerified)
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot save manifest cache because the target manifest is empty. Stage: save target manifest cache.");
return false; return false;
}
QDir dir(cacheDir); QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) { if (!dir.exists() && !dir.mkpath("."))
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create manifest cache directory. Stage: save target manifest cache. Directory: %1.")
.arg(cacheDir);
return false; return false;
}
QString version = m_manifest.value("version").toString(); QString version = m_manifest.value("version").toString();
QString filePath = cacheDir + "/manifest_" + version + ".json"; QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath); QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot write manifest cache file. Stage: save target manifest cache. File: %1. Error: %2.")
.arg(filePath, file.errorString());
return false; return false;
}
QJsonObject wrapper; QJsonObject wrapper;
wrapper["manifest"] = m_manifest; wrapper["manifest"] = m_manifest;
@@ -250,44 +329,55 @@ bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
file.write(doc.toJson(QJsonDocument::Indented)); file.write(doc.toJson(QJsonDocument::Indented));
file.close(); file.close();
qDebug() << "Manifest cached to" << filePath; qDebug() << "Manifest cached to" << filePath;
m_error.clear();
return true; return true;
} }
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version) bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
{ {
m_error.clear(); m_manifest = QJsonObject();
m_manifestText.clear();
m_fileItems.clear();
m_manifestSignatureVerified = false;
QString filePath = cacheDir + "/manifest_" + version + ".json"; QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath); QFile file(filePath);
if (!file.exists() || !file.open(QIODevice::ReadOnly)) { if (!file.exists() || !file.open(QIODevice::ReadOnly))
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot read cached signed manifest. Stage: read local manifest cache. Version: %1. File: %2. This cache is created after a version is installed successfully.")
.arg(version, filePath);
return false; return false;
}
QByteArray raw = file.readAll(); QByteArray raw = file.readAll();
file.close(); file.close();
QJsonDocument doc = QJsonDocument::fromJson(raw); QJsonParseError wrapperError;
if (!doc.isObject()) { const QJsonDocument doc = QJsonDocument::fromJson(raw, &wrapperError);
m_error = QCoreApplication::translate("UpdaterLogic", if (wrapperError.error != QJsonParseError::NoError || !doc.isObject())
"Cached signed manifest is not valid JSON. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath);
return false; return false;
}
QJsonObject wrapper = doc.object(); const QJsonObject wrapper = doc.object();
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) { const QJsonValue manifestTextValue = wrapper.value("manifest_text");
m_error = QCoreApplication::translate("UpdaterLogic", const QJsonValue cachedManifestValue = wrapper.value("manifest");
"Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.") if (!manifestTextValue.isString() || manifestTextValue.toString().isEmpty()
.arg(version, filePath); || !cachedManifestValue.isObject())
return false; return false;
}
m_manifest = wrapper["manifest"].toObject(); const QJsonValue signatureValue = cachedManifestValue.toObject().value("signature");
m_manifestText = wrapper["manifest_text"].toString(); 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; qDebug() << "Loaded cached manifest" << version;
m_error.clear();
return true; return true;
} }
@@ -340,27 +430,13 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
} }
QSet<QString> protectedPaths{ QSet<QString> protectedPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"),
QStringLiteral("launcher.exe"), QStringLiteral("launcher.exe"),
QStringLiteral("updater"), QStringLiteral("updater.exe")
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
}; };
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) { if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{ const QStringList runtimeProtected{
QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe")
QStringLiteral("launcher"), QStringLiteral("launcher.exe"),
QStringLiteral("updater"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
}; };
for (const QString& path : runtimeProtected) for (const QString& path : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + path); protectedPaths.insert(runtimePrefix + "/" + path);
@@ -371,6 +447,7 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
const QString folded = path.toCaseFolded(); const QString folded = path.toCaseFolded();
if (!isSafeRelativePath(path) || protectedPaths.contains(folded) if (!isSafeRelativePath(path) || protectedPaths.contains(folded)
|| isRuntimeProtectedPath(path)
|| newPaths.contains(folded) || seen.contains(folded)) || newPaths.contains(folded) || seen.contains(folded))
continue; continue;
seen.insert(folded); seen.insert(folded);
@@ -382,17 +459,8 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
{ {
m_error.clear(); if (m_manifest.isEmpty())
const QString stage = installedDir.isEmpty()
? QCoreApplication::translate("UpdaterLogic", "installed version verification")
: QCoreApplication::translate("UpdaterLogic", "downloaded/staged file verification");
const QString version = m_manifest.value(QStringLiteral("version")).toString();
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available. Stage: %1. The updater cannot know which files and hashes should be verified.")
.arg(stage);
return false; return false;
}
const QJsonArray files = m_manifest.value("files").toArray(); const QJsonArray files = m_manifest.value("files").toArray();
for (const auto& item : files) for (const auto& item : files)
@@ -400,12 +468,8 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
const QJsonObject fileObject = item.toObject(); const QJsonObject fileObject = item.toObject();
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString()); const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
const QString expectedSha = fileObject.value("sha256").toString(); const QString expectedSha = fileObject.value("sha256").toString();
if (!isSafeRelativePath(path)) { if (!isSafeRelativePath(path))
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest contains an unsafe file path. Stage: %1. Version: %2. Path: %3.")
.arg(stage, version, path);
return false; return false;
}
if (isRuntimeProtectedPath(path)) { if (isRuntimeProtectedPath(path)) {
qDebug() << "Runtime-protected manifest entry ignored:" << path; qDebug() << "Runtime-protected manifest entry ignored:" << path;
continue; continue;
@@ -418,23 +482,16 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
if (!QFile::exists(fullPath)) if (!QFile::exists(fullPath))
{ {
qDebug() << "Manifest file missing from staging and installation:" << path; qDebug() << "Manifest file missing from staging and installation:" << path;
m_error = QCoreApplication::translate("UpdaterLogic",
"A required file is missing. Stage: %1. Version: %2. Manifest path: %3. Checked path: %4. If this is a downloaded update, the file was not downloaded or staged correctly; if this is startup verification, the installed file may have been deleted.")
.arg(stage, version, path, fullPath);
return false; return false;
} }
const QString actualSha = calcLocalFileSha256(fullPath); const QString actualSha = calcLocalFileSha256(fullPath);
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
{ {
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha; qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
m_error = QCoreApplication::translate("UpdaterLogic",
"File SHA-256 does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected SHA-256: %5\nActual SHA-256: %6\nIf this happens during download, clear the update cache and retry. If this happens during startup or after installation, the local file differs from the published version.")
.arg(stage, version, path, fullPath, expectedSha, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
return false; return false;
} }
} }
qDebug() << "Full manifest validation passed using staging plus installed files"; qDebug() << "Full manifest validation passed using staging plus installed files";
m_error.clear();
return true; return true;
} }
@@ -442,51 +499,66 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
{ {
m_offlineError.clear(); m_offlineError.clear();
QFile package(packagePath); QFile package(packagePath);
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot open the offline update package"); 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; } 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); const QByteArray lengthBytes = package.read(8);
if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete"); return false; } if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete."); return false; }
quint64 headerSize = 0; quint64 headerSize = 0;
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8); 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 = QCoreApplication::translate("UpdaterLogic", "The offline package header length is invalid"); 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; QJsonParseError error;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error); const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header JSON is invalid"); 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 QJsonObject wrapper = wrapperDoc.object();
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8(); const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem"; 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; } 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(); const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package signature metadata is invalid"); 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; } 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(); QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; } if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid."); return false; }
manifest.insert("signature", wrapper.value("manifest_signature").toString()); manifest.insert("signature", wrapper.value("manifest_signature").toString());
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; } 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; } if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid."); return false; }
const qint64 payloadStart = 16 + qint64(headerSize); const qint64 payloadStart = 16 + qint64(headerSize);
const QJsonArray entries = packageMeta.value("files").toArray(); const QJsonArray entries = packageMeta.value("files").toArray();
for (const QJsonValue& value : entries) { for (const QJsonValue& value : entries) {
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-range data: %1").arg(path); return false; } if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-bounds data: %1").arg(path); return false; }
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi); FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
if (stagingDir.isEmpty()) continue; if (stagingDir.isEmpty()) continue;
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare offline file: %1").arg(path); return false; } const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare the offline file: %1").arg(path); return false; }
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create staged file: %1").arg(path); return false; } QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create the staged file: %1").arg(path); return false; }
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size; 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 = QCoreApplication::translate("UpdaterLogic", "Failed to read offline file: %1").arg(path); return false; } hash.addData(block); remaining -= block.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 = 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 verification or write failed: %1").arg(path); return false; } 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 = QCoreApplication::translate("UpdaterLogic", "The offline package file count does not match the 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; return true;
} }
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{ {
m_error.clear(); 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"; QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body; QJsonObject body;
body["app_id"] = appId; body["app_id"] = appId;
@@ -497,34 +569,98 @@ void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel,
QJsonArray emptyFiles; QJsonArray emptyFiles;
body["files"] = emptyFiles; body["files"] = emptyFiles;
m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp) m_http.postRequest(url, body, [this, signedFiles](int code, const QJsonObject& resp)
{ {
qDebug() << "Download URL API returned code:" << code; qDebug() << "Download URL API returned code:" << code;
m_fileItems.clear(); m_fileItems.clear();
if (code == 200) if (code == 200)
{ {
QJsonArray fileArr = resp["files"].toArray(); const QJsonValue filesValue = resp.value("files");
for (auto item : fileArr) if (!filesValue.isArray())
{ {
QJsonObject obj = item.toObject(); qDebug() << "Download URL response files field is invalid";
FileDownloadItem fi; emit fetchUrlFinished();
fi.path = obj["path"].toString(); return;
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;
} }
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 else
{ {
qDebug() << "Failed to get download URL"; qDebug() << "Failed to get download URL";
const QString detail = serverDetailMessage(resp);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot get secure download URLs. Stage: request download URLs. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
.arg(QString::number(code), appId, channel, targetVer, QString::number(versionId),
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
} }
emit fetchUrlFinished(); emit fetchUrlFinished();
}); });
@@ -551,18 +687,10 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
const QString& resumePartPath) const QString& resumePartPath)
{ {
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath; const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
const QString displayPath = m_currentDownloadPath.isEmpty() ? savePath : m_currentDownloadPath; if (!QDir().mkpath(QFileInfo(partPath).path())) return false;
if (!QDir().mkpath(QFileInfo(partPath).path())) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create partial download directory. Stage: download file. File: %1. Partial file: %2.")
.arg(displayPath, partPath);
return false;
}
if (QFile::exists(savePath) if (QFile::exists(savePath)
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) { && calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0)
m_error.clear();
return true; return true;
}
QFile::remove(savePath); QFile::remove(savePath);
for (int attempt = 0; attempt < 4; ++attempt) for (int attempt = 0; attempt < 4; ++attempt)
@@ -578,19 +706,8 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{ {
QFile::remove(savePath); QFile::remove(savePath);
if (QFile::rename(partPath, savePath)) { return QFile::rename(partPath, savePath);
m_error.clear();
return true;
} }
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
}
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached partial file has the expected size but wrong SHA-256. Stage: resume download. File: %1.\nExpected SHA-256: %2\nActual SHA-256: %3\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, expectSha256, actualSha);
QFile::remove(partPath); QFile::remove(partPath);
existingSize = 0; existingSize = 0;
} }
@@ -601,9 +718,6 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (!partFile.open(mode)) if (!partFile.open(mode))
{ {
qDebug() << "Cannot open partial download:" << partPath; qDebug() << "Cannot open partial download:" << partPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open partial download file. Stage: download file. File: %1. Partial file: %2. Error: %3.")
.arg(displayPath, partPath, partFile.errorString());
return false; return false;
} }
@@ -643,7 +757,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (existingSize > 0 && httpStatus == 200) if (existingSize > 0 && httpStatus == 200)
{ {
// The server ignored Range; the current file is "old fragment + full response" and must be redownloaded safely. // The server ignored Range, so restart instead of appending a full response to the partial file.
qDebug() << "Server ignored Range; restart full download:" << savePath; qDebug() << "Server ignored Range; restart full download:" << savePath;
QFile::remove(partPath); QFile::remove(partPath);
} }
@@ -657,37 +771,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (QFile::rename(partPath, savePath)) if (QFile::rename(partPath, savePath))
{ {
qDebug() << "Download completed/resumed & sha pass:" << savePath; qDebug() << "Download completed/resumed & sha pass:" << savePath;
m_error.clear();
return true; return true;
} }
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
} }
else if (expectedSize >= 0 && actualSize >= expectedSize) else if (expectedSize >= 0 && actualSize >= expectedSize)
{ {
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath; qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file does not match the signed manifest. Stage: download file. File: %1. HTTP status: %2.\nExpected size: %3 bytes\nActual size: %4 bytes\nExpected SHA-256: %5\nActual SHA-256: %6\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize),
expectSha256, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
QFile::remove(partPath); QFile::remove(partPath);
} }
else {
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file is incomplete. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes, current size: %4 bytes.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize));
}
} }
else else
{ {
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1 qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size(); << savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
m_error = QCoreApplication::translate("UpdaterLogic",
"Download request failed. Stage: download file. File: %1. Attempt: %2/4. HTTP status: %3. Network error: %4. Partial file: %5.")
.arg(displayPath, QString::number(attempt + 1), QString::number(httpStatus), networkError, partPath);
} }
if (attempt < 3) if (attempt < 3)
@@ -702,37 +798,13 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
} }
} }
} }
if (m_error.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"File download failed after retries. Stage: download file. File: %1.")
.arg(displayPath);
}
return false; return false;
} }
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{ {
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); return UpdateClient::isRuntimeProtectedPath(
QSet<QString> protectedPaths{ path, ConfigHelper::instance().runtimeRelativePath());
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(normalized);
} }
bool UpdaterLogic::isSafeRelativePath(const QString& path) const bool UpdaterLogic::isSafeRelativePath(const QString& path) const
@@ -774,29 +846,18 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir) bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
{ {
m_error.clear();
if (m_fileItems.isEmpty()) if (m_fileItems.isEmpty())
{ {
m_downloadAllOk = false; m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"The target version manifest contains no downloadable files. Stage: prepare downloads.");
return false; return false;
} }
QDir stagingDir(tempDir); QDir stagingDir(tempDir);
if (!FileHelper::createDir(tempDir)) { if (!FileHelper::createDir(tempDir))
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create update staging directory. Stage: prepare downloads. Directory: %1.")
.arg(tempDir);
return false; return false;
}
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
if (!FileHelper::createDir(resumeCacheDir)) { if (!FileHelper::createDir(resumeCacheDir))
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create download cache directory. Stage: prepare downloads. Directory: %1.")
.arg(resumeCacheDir);
return false; return false;
}
QSet<QString> activePartialNames; QSet<QString> activePartialNames;
for (const auto& item : m_fileItems) for (const auto& item : m_fileItems)
activePartialNames.insert(item.sha256.toLower() + ".part"); activePartialNames.insert(item.sha256.toLower() + ".part");
@@ -826,9 +887,6 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
{ {
qDebug() << "Unsafe relative path in manifest:" << fi.path; qDebug() << "Unsafe relative path in manifest:" << fi.path;
m_downloadAllOk = false; m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest contains an unsafe file path. Stage: prepare file download. Path: %1.")
.arg(fi.path);
return false; return false;
} }
@@ -851,9 +909,6 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
{ {
qDebug() << "Cannot create staging subdirectory:" << parentDir; qDebug() << "Cannot create staging subdirectory:" << parentDir;
m_downloadAllOk = false; m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create staging subdirectory. Stage: prepare file download. File: %1. Directory: %2.")
.arg(relativePath, parentDir);
return false; return false;
} }
@@ -875,7 +930,6 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
} }
m_downloadAllOk = true; m_downloadAllOk = true;
m_error.clear();
return true; return true;
} }
+5 -3
View File
@@ -25,13 +25,12 @@ public:
explicit UpdaterLogic(QObject* parent = nullptr); explicit UpdaterLogic(QObject* parent = nullptr);
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId); void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
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 validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const; bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version); bool loadManifestCache(const QString& cacheDir, const QString& version);
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString()); bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
QString offlineError() const { return m_offlineError; } QString offlineError() const { return m_offlineError; }
QString errorString() const { return m_error; }
void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId); void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
@@ -54,6 +53,9 @@ signals:
private: private:
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256, bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
qint64 expectedSize, const QString& resumePartPath); 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 isSafeRelativePath(const QString& path) const;
bool isRuntimeProtectedPath(const QString& path) const; bool isRuntimeProtectedPath(const QString& path) const;
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const; QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
@@ -64,12 +66,12 @@ private:
QJsonObject m_manifest; QJsonObject m_manifest;
QString m_manifestText; QString m_manifestText;
QList<FileDownloadItem> m_fileItems; QList<FileDownloadItem> m_fileItems;
mutable bool m_manifestSignatureVerified = false;
bool m_downloadAllOk = false; bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0; qint64 m_downloadTotalBytes = 0;
qint64 m_downloadCompletedBytes = 0; qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0; qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath; QString m_currentDownloadPath;
QString m_offlineError; QString m_offlineError;
mutable QString m_error;
QElapsedTimer m_downloadTimer; QElapsedTimer m_downloadTimer;
}; };
+153 -154
View File
@@ -1,37 +1,32 @@
#include <QApplication> #include <QApplication>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QSaveFile> #include <QSaveFile>
#include <QStorageInfo> #include <QStorageInfo>
#include <QThread> #include <QThread>
#include <QTranslator>
#include "UpdaterLogic.h" #include "UpdaterLogic.h"
#include "UpdateTransaction.h" #include "UpdateTransaction.h"
#include "FileHelper.h" #include "FileHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "TicketHelper.h" #include "TicketHelper.h"
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "TranslationHelper.h"
#include <QVersionNumber> #include <QVersionNumber>
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater"); QApplication::setApplicationName("Marsco Updater");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested(); TranslationHelper translation;
if (elevatedWriteExitCode >= 0) translation.installForSystemLocale();
return elevatedWriteExitCode;
UpdaterLogic logic; UpdaterLogic logic;
QString offlinePackagePath; QString offlinePackagePath;
@@ -42,9 +37,7 @@ int main(int argc, char* argv[])
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
if (!logic.loadOfflinePackage(offlinePackagePath)) { if (!logic.loadOfflinePackage(offlinePackagePath)) {
QMessageBox::critical(nullptr, QMessageBox::critical(nullptr, QCoreApplication::translate("Updater", "Invalid Offline Package"), logic.offlineError());
QCoreApplication::translate("Updater", "Invalid Offline Package"),
logic.offlineError());
return -1; return -1;
} }
const QJsonObject packageManifest = logic.getManifest(); const QJsonObject packageManifest = logic.getManifest();
@@ -54,9 +47,10 @@ int main(int argc, char* argv[])
targetVersionId = packageManifest.value("manifest_seq").toInt(); targetVersionId = packageManifest.value("manifest_seq").toInt();
} else { } else {
if (argc < 5) { if (argc < 5) {
QMessageBox::critical(nullptr, QMessageBox::critical(
QCoreApplication::translate("Updater", "Updater Argument Error"), nullptr,
QCoreApplication::translate("Updater", "The updater is missing online update arguments or an offline update package.")); QCoreApplication::translate("Updater", "Invalid Updater Arguments"),
QCoreApplication::translate("Updater", "The updater requires online update arguments or an offline update package."));
return -1; return -1;
} }
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt(); appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
@@ -75,30 +69,40 @@ int main(int argc, char* argv[])
const QString updateDir = config.updateRoot(); const QString updateDir = config.updateRoot();
QDir().mkpath(updateDir); QDir().mkpath(updateDir);
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) { 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", "Offline Package Not Applicable"),
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration.")); QCoreApplication::translate("Updater", "The update package application or channel does not match this installation."));
return -1; return -1;
} }
QString fromVersion = config.getValue("App", "current_version"); QString fromVersion = config.getValue("App", "current_version");
if (!offlinePackagePath.isEmpty()) { PolicyHelper updatePolicy(runtimeDir);
PolicyHelper offlinePolicy(runtimeDir); const bool policyApplicable =
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired() updatePolicy.loadPolicy() && updatePolicy.isValid()
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) { && updatePolicy.isApplicable(appId, channel, fromVersion)
QMessageBox::critical(nullptr, && !updatePolicy.isExpired()
QCoreApplication::translate("Updater", "Offline Update Rejected"), && updatePolicy.isVersionAllowed(targetVersion);
QCoreApplication::translate("Updater", "The local signed policy has expired, forbids offline updates, or does not allow the target version.")); 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; return -1;
} }
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion), if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
QVersionNumber::fromString(fromVersion)) < 0 QVersionNumber::fromString(fromVersion)) < 0
&& !offlinePolicy.allowRollback()) { && !updatePolicy.allowRollback())
QMessageBox::critical(nullptr, {
QCoreApplication::translate("Updater", "Downgrade Forbidden"), QMessageBox::critical(
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an offline package with a lower version.")); nullptr,
QCoreApplication::translate("Updater", "Downgrade Prohibited"),
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an older version."));
return -1; return -1;
} }
}
QString deviceId = config.getValue("Update", "device_id"); QString deviceId = config.getValue("Update", "device_id");
if (deviceId.isEmpty()) deviceId = "unknown_device"; if (deviceId.isEmpty()) deviceId = "unknown_device";
@@ -106,24 +110,26 @@ int main(int argc, char* argv[])
QString restoredVersion; QString restoredVersion;
QString recoveryError; QString recoveryError;
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) { if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Update Recovery Failed"), QCoreApplication::translate("Updater", "Update Recovery Failed"),
QCoreApplication::translate("Updater", "An unfinished update was detected, but the old version could not be restored: %1\nDo not continue running the software. Please contact the administrator.") 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)); .arg(recoveryError));
return -1; return -1;
} }
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) { if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
if (!config.setValue("App", "current_version", restoredVersion)) { if (!config.setValue("App", "current_version", restoredVersion)) {
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Update Recovery Failed"), QCoreApplication::translate("Updater", "Update Recovery Failed"),
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory.")); 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; return -1;
} }
fromVersion = restoredVersion; fromVersion = restoredVersion;
} }
} }
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing update..."), QString(), 0, 100); QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing the update..."), QString(), 0, 100);
progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion)); progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
progress.setCancelButton(nullptr); progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
@@ -153,10 +159,10 @@ int main(int argc, char* argv[])
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) { [&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60; const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
const QString fileText = path.isEmpty() const QString fileText = path.isEmpty()
? QCoreApplication::translate("Updater", "Preparing download...") ? QCoreApplication::translate("Updater", "Preparing the download...")
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path); : QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
progress.setValue(value); 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), .arg(fileText, formatBytes(received), formatBytes(total),
formatBytes(static_cast<qint64>(bytesPerSecond)))); formatBytes(static_cast<qint64>(bytesPerSecond))));
QApplication::processEvents(); QApplication::processEvents();
@@ -173,18 +179,13 @@ int main(int argc, char* argv[])
QMessageBox::critical(nullptr, title, message); QMessageBox::critical(nullptr, title, message);
return -1; return -1;
}; };
const auto withDetails = [](const QString& message, const QString& details) {
return details.trimmed().isEmpty()
? message
: message + QCoreApplication::translate("Updater", "\n\nDetails:\n%1").arg(details);
};
const auto configuredName = [&](const QString& key, const QString& fallback) { const auto configuredName = [&](const QString& key, const QString& fallback) {
return ConfigHelper::executableNameForCurrentPlatform( const QString value = config.getValue("Runtime", key).trimmed();
config.getValue("Runtime", key), fallback); return value.isEmpty() ? fallback : value;
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp"); const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater"); const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap"); const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
bool timeoutOk = false; bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
@@ -192,38 +193,19 @@ int main(int argc, char* argv[])
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable); const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable); const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
const QString launchToken = config.getValue("App", "launch_token"); const QString launchToken = config.getValue("App", "launch_token");
QString mainStartupError;
const auto launchMainApp = [&](const QString& healthFile = QString()) { const auto launchMainApp = [&](const QString& healthFile = QString()) {
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
const QString launchVersion = config.getValue("App", "current_version"); const QString launchVersion = config.getValue("App", "current_version");
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken, if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
&ticketPath, &ticketError)) { &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false; return false;
} }
QStringList args{QString("--ticket-file=%1").arg(ticketPath)}; QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
const bool started = QProcess::startDetached(mainAppPath, args); const bool started = QProcess::startDetached(mainAppPath, args);
if (!started) { if (!started) QFile::remove(ticketPath);
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nHealth file: %3\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath, healthFile.isEmpty() ? QCoreApplication::translate("Updater", "<not used>") : healthFile);
}
return started; return started;
}; };
const auto bootstrapPlanFile = [&]() { const auto bootstrapPlanFile = [&]() {
@@ -240,7 +222,7 @@ int main(int argc, char* argv[])
const auto delegateRollback = [&](const QString& title, const QString& reason) { const auto delegateRollback = [&](const QString& title, const QString& reason) {
FileHelper::killProcess(mainExecutable); FileHelper::killProcess(mainExecutable);
transaction.markRollbackRequired(reason); transaction.markRollbackRequired(reason);
progress.setLabelText(QCoreApplication::translate("Updater", "Delegating rollback to Bootstrap...")); progress.setLabelText(QCoreApplication::translate("Updater", "Handing rollback over to Bootstrap..."));
QApplication::processEvents(); QApplication::processEvents();
if (launchBootstrap("rollback")) { if (launchBootstrap("rollback")) {
progress.close(); progress.close();
@@ -249,7 +231,7 @@ int main(int argc, char* argv[])
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, title, QMessageBox::critical(nullptr, title,
reason + QCoreApplication::translate("Updater", "\n\nCannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator.")); 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; return -1;
}; };
@@ -258,7 +240,8 @@ int main(int argc, char* argv[])
if (!transaction.resumeExisting(&resumeError)) { if (!transaction.resumeExisting(&resumeError)) {
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Transaction Resume Failed"), QCoreApplication::translate("Updater", "Transaction Resume Failed"),
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError)); QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
return -1; return -1;
@@ -272,54 +255,59 @@ int main(int argc, char* argv[])
reportUpdateResult(false); reportUpdateResult(false);
if (stateOk) launchMainApp(); if (stateOk) launchMainApp();
progress.close(); progress.close();
QMessageBox::warning(nullptr, QMessageBox::warning(
nullptr,
QCoreApplication::translate("Updater", "Update Rolled Back"), QCoreApplication::translate("Updater", "Update Rolled Back"),
stateOk stateOk
? QCoreApplication::translate("Updater", "The new version failed to install or start. The old version has been restored and started automatically.") ? 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. Please check configuration directory permissions.")); : 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; return stateOk ? 0 : -1;
} }
if (bootstrapResult != "success") { if (bootstrapResult != "success") {
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Automatic Rollback Failed"), QCoreApplication::translate("Updater", "Automatic Rollback Failed"),
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator.")); QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator."));
return -1; return -1;
} }
} else if (!transaction.initialize()) { } else if (!transaction.initialize()) {
return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"), return fail(
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save the transaction state."), QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save its state."),
"transaction_init_failed"); "transaction_init_failed");
} else if (!offlinePackagePath.isEmpty()) { } else if (!offlinePackagePath.isEmpty()) {
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode")); QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8) if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"), return fail(
QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."), QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
"offline_marker_failed"); "offline_marker_failed");
} }
setProgress(resumingFromBootstrap ? 72 : 10, setProgress(
resumingFromBootstrap ? 72 : 10,
offlinePackagePath.isEmpty() offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Fetching and verifying version manifest...") ? QCoreApplication::translate("Updater", "Fetching and validating the version manifest...")
: QCoreApplication::translate("Updater", "Verifying offline update package...")); : QCoreApplication::translate("Updater", "Validating the offline update package..."));
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache"); const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
if (resumingFromBootstrap) { if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"), return delegateRollback(
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."), QCoreApplication::translate("Updater", "Manifest Cache Failed"),
logic.errorString())); QCoreApplication::translate("Updater", "The signed manifest cache could not be read after Bootstrap installation."));
} else if (offlinePackagePath.isEmpty()) { } else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.getManifest(appId, channel, targetVersion, targetVersionId);
} }
if (!logic.verifyManifestSignature()) { if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"), return delegateRollback(
withDetails(QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."), QCoreApplication::translate("Updater", "Security Validation Failed"),
logic.errorString())); QCoreApplication::translate("Updater", "The version manifest signature could not be revalidated after Bootstrap installation."));
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"), return fail(
withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."), QCoreApplication::translate("Updater", "Security Validation Failed"),
logic.errorString()), QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Contact an administrator."),
"manifest_signature_invalid"); "manifest_signature_invalid");
} }
QStringList obsoletePaths; QStringList obsoletePaths;
@@ -336,26 +324,27 @@ int main(int argc, char* argv[])
} }
if (!logic.saveManifestCache(manifestCacheDir)) { if (!logic.saveManifestCache(manifestCacheDir)) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"), return delegateRollback(
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."), QCoreApplication::translate("Updater", "Manifest Cache Failed"),
logic.errorString())); QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"), return fail(
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."), QCoreApplication::translate("Updater", "Manifest Cache Failed"),
logic.errorString()), QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
"manifest_cache_failed"); "manifest_cache_failed");
} }
if (!resumingFromBootstrap) { if (!resumingFromBootstrap) {
setProgress(25, setProgress(
25,
offlinePackagePath.isEmpty() offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Fetching secure download URLs...") ? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
: QCoreApplication::translate("Updater", "Preparing offline package files...")); : QCoreApplication::translate("Updater", "Preparing offline package files..."));
if (offlinePackagePath.isEmpty()) if (offlinePackagePath.isEmpty())
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
if (logic.getFileList().isEmpty()) if (logic.getFileList().isEmpty())
return fail(QCoreApplication::translate("Updater", "No Files to Update"), return fail(
withDetails(QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."), QCoreApplication::translate("Updater", "No Update Files"),
logic.errorString()), QCoreApplication::translate("Updater", "The server returned no version files. The update has stopped."),
"empty_file_list"); "empty_file_list");
QStorageInfo storage(updateDir); QStorageInfo storage(updateDir);
@@ -363,39 +352,39 @@ int main(int argc, char* argv[])
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths); const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
const qint64 availableBytes = storage.bytesAvailable(); const qint64 availableBytes = storage.bytesAvailable();
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) { if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
return fail(QCoreApplication::translate("Updater", "Insufficient Disk Space"), return fail(
QCoreApplication::translate("Updater", QCoreApplication::translate("Updater", "Insufficient Disk Space"),
"The update requires at least %1 of free space, but the installation drive currently has only %2.\n" 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.")
"The required space includes downloaded files, old version backups, and a safety margin.")
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))), .arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
"disk_space_insufficient"); "disk_space_insufficient");
} }
const QString stagingDir = transaction.stagingDir(); const QString stagingDir = transaction.stagingDir();
setProgress(30, offlinePackagePath.isEmpty() setProgress(
? QCoreApplication::translate("Updater", "Downloading and verifying %1 version files...").arg(logic.getFileList().size()) 30,
: QCoreApplication::translate("Updater", "Extracting and verifying %1 offline files...").arg(logic.getFileList().size())); (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 (!offlinePackagePath.isEmpty()) {
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir)) if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"), return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"), logic.offlineError(), "offline_package_invalid");
logic.offlineError(),
"offline_package_invalid");
} else { } else {
if (!logic.downloadAllFiles(stagingDir, targetDir)) { if (!logic.downloadAllFiles(stagingDir, targetDir)) {
logic.reportDownloadResult(appId, channel, targetVersion, false); logic.reportDownloadResult(appId, channel, targetVersion, false);
return fail(QCoreApplication::translate("Updater", "Download Failed"), return fail(
withDetails(QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network, update cache, or server release files and try again."), QCoreApplication::translate("Updater", "Download Failed"),
logic.errorString()), QCoreApplication::translate("Updater", "Some files could not be downloaded or failed SHA-256 validation. Check the network and try again."),
"download_failed"); "download_failed");
} }
logic.reportDownloadResult(appId, channel, targetVersion, true); logic.reportDownloadResult(appId, channel, targetVersion, true);
} }
setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files...")); setProgress(58, QCoreApplication::translate("Updater", "Validating the complete version files..."));
if (!logic.validateLocalFiles(stagingDir, targetDir)) if (!logic.validateLocalFiles(stagingDir, targetDir))
return fail(QCoreApplication::translate("Updater", "File Verification Failed"), return fail(
withDetails(QCoreApplication::translate("Updater", "The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files."), QCoreApplication::translate("Updater", "File Validation Failed"),
logic.errorString()), QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
"staging_verify_failed"); "staging_verify_failed");
QStringList changedPaths; QStringList changedPaths;
@@ -408,33 +397,37 @@ int main(int argc, char* argv[])
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable); runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
for (const QString& path : changedPaths) { for (const QString& path : changedPaths) {
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0) if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
return fail(QCoreApplication::translate("Updater", "Bootstrap Cannot Self-Update"), return fail(
QCoreApplication::translate("Updater", "This version contains a new %1. Please upgrade this component with the installer, then publish the business version again.") QCoreApplication::translate("Updater", "Bootstrap Cannot Update Itself"),
.arg(bootstrapManifestPath), 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"); "bootstrap_self_update_blocked");
} }
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths)) if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
return fail(QCoreApplication::translate("Updater", "Transaction Recording Failed"), return fail(
QCoreApplication::translate("Updater", "Cannot save the list of verified or pending deletion files. The update has stopped."), 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"); "transaction_record_failed");
setProgress(66, QCoreApplication::translate("Updater", "Closing the main application...")); setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
if (!FileHelper::killProcess(mainExecutable)) if (!FileHelper::killProcess(mainExecutable))
return fail(QCoreApplication::translate("Updater", "Cannot Close Main Application"), return fail(
QCoreApplication::translate("Updater", "%1 is still running. Please close it manually and try again.").arg(mainExecutable), 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"); "mainapp_close_failed");
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 files to be deleted...") setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 deletions...")
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size())); .arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
if (!transaction.backupCurrentFiles()) if (!transaction.backupCurrentFiles())
return fail(QCoreApplication::translate("Updater", "Backup Failed"), return fail(
QCoreApplication::translate("Updater", "Cannot back up current version files. The new version has not been installed. Please check disk space and directory permissions."), 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"); "backup_failed");
const QString planFile = bootstrapPlanFile(); const QString planFile = bootstrapPlanFile();
QSaveFile plan(planFile); QSaveFile plan(planFile);
if (!plan.open(QIODevice::WriteOnly)) if (!plan.open(QIODevice::WriteOnly))
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"), return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."), QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
"bootstrap_plan_failed"); "bootstrap_plan_failed");
const auto writePlanItem = [&](char operation, const QString& path) { const auto writePlanItem = [&](char operation, const QString& path) {
@@ -444,7 +437,8 @@ int main(int argc, char* argv[])
for (const QString& path : changedPaths) { for (const QString& path : changedPaths) {
if (!writePlanItem('C', path)) { if (!writePlanItem('C', path)) {
plan.cancelWriting(); plan.cancelWriting();
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"), return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."), QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
"bootstrap_plan_failed"); "bootstrap_plan_failed");
} }
@@ -452,50 +446,54 @@ int main(int argc, char* argv[])
for (const QString& path : obsoletePaths) { for (const QString& path : obsoletePaths) {
if (!writePlanItem('D', path)) { if (!writePlanItem('D', path)) {
plan.cancelWriting(); plan.cancelWriting();
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"), return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."), QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
"bootstrap_plan_failed"); "bootstrap_plan_failed");
} }
} }
if (!plan.commit() || !transaction.markAwaitingBootstrap()) if (!plan.commit() || !transaction.markAwaitingBootstrap())
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"), return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."), QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
"bootstrap_plan_failed"); "bootstrap_plan_failed");
setProgress(78, QCoreApplication::translate("Updater", "Delegating installation to Bootstrap...")); setProgress(78, QCoreApplication::translate("Updater", "Handing installation over to Bootstrap..."));
if (!launchBootstrap("install")) if (!launchBootstrap("install"))
return fail(QCoreApplication::translate("Updater", "Bootstrap Startup Failed"), return fail(
QCoreApplication::translate("Updater", "Cannot start the standalone update handoff program: %1").arg(bootstrapPath), QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
QCoreApplication::translate("Updater", "Cannot start the independent update handoff program: %1").arg(bootstrapPath),
"bootstrap_start_failed"); "bootstrap_start_failed");
progress.close(); progress.close();
return 0; return 0;
} }
setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result...")); setProgress(82, QCoreApplication::translate("Updater", "Validating the Bootstrap installation result..."));
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir)) if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"), return delegateRollback(
withDetails(QCoreApplication::translate("Updater", "New version files failed verification after installation. The updater will roll back to the previous version."), QCoreApplication::translate("Updater", "Installation Validation Failed"),
logic.errorString())); QCoreApplication::translate("Updater", "The new version files failed validation after installation."));
for (const QString& path : transaction.obsoletePaths()) { for (const QString& path : transaction.obsoletePaths()) {
if (QFile::exists(QDir(targetDir).filePath(path))) if (QFile::exists(QDir(targetDir).filePath(path)))
return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"), return delegateRollback(
QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path)); QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
} }
setProgress(89, QCoreApplication::translate("Updater", "Saving new version state...")); setProgress(89, QCoreApplication::translate("Updater", "Saving the new version state..."));
if (!config.setValue("App", "current_version", targetVersion) if (!config.setValue("App", "current_version", targetVersion)
|| config.getValue("App", "current_version") != targetVersion) || config.getValue("App", "current_version") != targetVersion)
return delegateRollback(QCoreApplication::translate("Updater", "State Save Failed"), return delegateRollback(
QCoreApplication::translate("Updater", "State Save Failed"),
QCoreApplication::translate("Updater", "Cannot save the current version number.")); QCoreApplication::translate("Updater", "Cannot save the current version number."));
const QString healthFile = transaction.healthFile(); const QString healthFile = transaction.healthFile();
QFile::remove(healthFile); QFile::remove(healthFile);
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation...")); setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for a health confirmation..."));
if (!launchMainApp(healthFile)) if (!launchMainApp(healthFile))
return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"), return delegateRollback(
mainStartupError.isEmpty() QCoreApplication::translate("Updater", "Startup Failed"),
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable) QCoreApplication::translate("Updater", "%1 could not be started.").arg(mainExecutable));
: mainStartupError);
QElapsedTimer healthTimer; QElapsedTimer healthTimer;
healthTimer.start(); healthTimer.start();
@@ -504,23 +502,24 @@ int main(int argc, char* argv[])
QThread::msleep(100); QThread::msleep(100);
} }
if (!QFile::exists(healthFile)) if (!QFile::exists(healthFile))
return delegateRollback(QCoreApplication::translate("Updater", "Startup Confirmation Failed"), return delegateRollback(
QCoreApplication::translate("Updater", "The new version did not complete startup health confirmation within %1 milliseconds.") QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
.arg(healthCheckTimeoutMs)); QCoreApplication::translate("Updater", "The new version did not complete its startup health confirmation within %1 milliseconds.").arg(healthCheckTimeoutMs));
setProgress(99, QCoreApplication::translate("Updater", "Committing update transaction...")); setProgress(99, QCoreApplication::translate("Updater", "Committing the update transaction..."));
if (!transaction.commit()) if (!transaction.commit())
return delegateRollback(QCoreApplication::translate("Updater", "Transaction Commit Failed"), return delegateRollback(
QCoreApplication::translate("Updater", "The new version has started, but the update transaction could not be committed.")); 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(healthFile);
QFile::remove(bootstrapPlanFile()); QFile::remove(bootstrapPlanFile());
reportUpdateResult(true); reportUpdateResult(true);
progress.setValue(100); progress.setValue(100);
progress.close(); progress.close();
QMessageBox::information(nullptr, QMessageBox::information(
nullptr,
QCoreApplication::translate("Updater", "Update Complete"), QCoreApplication::translate("Updater", "Update Complete"),
QCoreApplication::translate("Updater", "The software has been successfully updated to %1 and passed the startup health check.") QCoreApplication::translate("Updater", "The software was updated successfully to %1 and passed the startup health check.").arg(targetVersion));
.arg(targetVersion));
return 0; return 0;
} }
+82
View File
@@ -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)."
)
+329
View File
@@ -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()
+889
View File
@@ -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 "&" "&amp;" _escaped "${_escaped}")
string(REPLACE "<" "&lt;" _escaped "${_escaped}")
string(REPLACE ">" "&gt;" _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()
+9 -4
View File
@@ -1,15 +1,14 @@
{ {
"schema_version": 1,
"app_id": "simcae", "app_id": "simcae",
"app_name": "SimCAE", "app_name": "SimCAE",
"channel": "stable", "channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3", "client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "", "api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026", "client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000", "request_timeout_ms": "5000",
"temp_folder": "update_temp", "temp_folder": "update_temp",
"device_id": "",
"install_root": "..", "install_root": "..",
"main_executable": "SimCAE.exe", "main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe", "launcher_executable": "Launcher.exe",
@@ -17,5 +16,11 @@
"bootstrap_executable": "Bootstrap.exe", "bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000", "health_check_timeout_ms": "15000",
"platform": "windows", "platform": "windows",
"arch": "x64" "arch": "x64",
"initial_state": {
"current_version": "1.0.0",
"license_key": "",
"device_id": "",
"installation_id": ""
}
} }
-21
View File
@@ -1,21 +0,0 @@
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE",
"launcher_executable": "Launcher",
"updater_executable": "Updater",
"bootstrap_executable": "Bootstrap",
"health_check_timeout_ms": "15000",
"platform": "linux",
"arch": "x64"
}
+7 -7
View File
@@ -1,9 +1,9 @@
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX1FSi06eP8XhX4B3Oy6 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
FzfkTe3FBIg6OjWpT1541LivRTRt9HXZ30nKuIs5itXBzBQPMU8hsfR0MD9nfPG/ WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
NlajfZzqbyxAJlUMeThJiwtyz98wv3VE1hS2Bwuc3mbmtfU0zl/MoPdWrluL3x3C Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
bt0ylL00rLBuvjgG21zDflVqj3w9CwpKHFsNrSYoI6vOFX9YrRZ9tKcPEkSRPqts Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
411QkAQLtMiOMIIhNe5aFIL7doCnglx32hJeHNCTUSxjM9VyHUTEj8wKN/6Gy5iP Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
YZGRafeiJZ32RRIHyssereAg3Xe/3scd+tbFNNYP9xrRhRgDacmkSCBodnbJd4Qc F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
rQIDAQAB TwIDAQAB
-----END PUBLIC KEY----- -----END PUBLIC KEY-----
-3
View File
@@ -1,3 +0,0 @@
{
"api_base_url": "http://192.168.229.128:8000"
}
-5
View File
@@ -1,5 +0,0 @@
<RCC>
<qresource prefix="/simcae">
<file>server_config.json</file>
</qresource>
</RCC>
+162
View File
@@ -0,0 +1,162 @@
# 更新客户端架构
## 目的
更新客户端强制执行一条受控的产品入口路径,并执行事务性更新,而不会将可编辑的安装文件作为信任边界的一部分。父项目 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,并支持受控的公钥轮换窗口。验证失败将导致系统关闭。
首次安装可能尚无当前版本的本地清单缓存。当设备在线、当前版本已在服务器显式发布且没有更高目标版本时,Launcher 会通过与 Updater 相同的校验逻辑获取当前版本清单,验证签名和身份后写入缓存,再执行完整文件校验。服务器未发布当前版本、返回无效版本 ID、清单签名无效或缓存写入失败时,启动保持关闭失败。此流程不会自动创建或发布服务器版本。
## 运行时布局
父安装程序拥有最终的布局。预期的可执行文件关系为:
```text
<install-root>/
bin/
Launcher<platform-suffix>
Updater<platform-suffix>
Bootstrap<platform-suffix>
SimCAE<platform-suffix>
config/
<仅在需要时缓存已签名的运行时>
```
安装目录中不应包含可编辑的产品策略 JSON 文件。更新缓存、备份、已签名策略缓存和机器状态必须使用集成运行时选择的位置,并且必须遵守平台的写入权限规则。
## 失败策略
* 无效签名、不安全路径、身份不匹配、策略回滚以及受保护文件损坏将导致系统关闭。
* 一个临时的网络故障可能会使用仍然有效的已签名离线策略;它不能静默地创建新的允许项。
* 过期的离线策略或被禁止的版本会阻止启动。
* 失败的候选健康触发器会导致回滚。
* 回滚失败会留下可恢复的状态和诊断错误;它不能报告更新成功。
## 接受场景
发布接受必须涵盖:
* 通过 `Launcher` 正常启动;
* 直接 SimCAE 启动拒绝;
* 首次设备注册及后续离线启动;
* 更新到较新的已发布版本;
* 篡改的受保护文件被拒绝;
* 篡改的策略或清单被拒绝;
* 强制更新和禁用版本行为;
* 频道选择和禁止降级行为;
* 中断更新恢复;
* 候选失败和成功回滚;
* 从实际安装位置启动并更新,通过正常的最终用户交互并需要提升权限。
+127
View File
@@ -0,0 +1,127 @@
# 构建和打包
## 所有权
父级 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 包提示。
* 未跟踪的 `config/config.json`,其中包含真实 API 端点和已签发、非空的 `initial_state.license_key`
仓库的 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 构建目录中。
服务器发布仍然是一个显式的发布操作。构建安装程序不会创建或发布服务器版本、策略、清单或许可证。
`package_installer` 会在 staging 之前校验所选产品配置,并拒绝示例端点、空授权或明显的授权占位符。新增或修改 `config/config.json` 后必须重新运行 CMake configure,使 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 运行时。
独立输出是临时的开发者输出。不要将其压缩或发送给用户。
## 包接受检查清单
在交付前,请对照干净的安装阶段验证以下所有内容:
* LauncherUpdaterBootstrap 和 SimCAE 存在于预期的运行时目录中。
* 已安装的用户入口点和快捷方式启动 Launcher,而不是 SimCAE。
* 在 Release 版本中拒绝直接启动 SimCAE。
* 未安装 app\_config.jsonclient.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` 写入作为变通方法。
### 更新应用程序回滚
检查更新事务的诊断信息,并确认候选进程在配置的超时时间之前已写入请求的健康标记。绝不要强制提交未经验证的候选进程。
+138
View File
@@ -0,0 +1,138 @@
# 配置与安全
## 配置模型
配置分为两个拥有不同所有者的类别:
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` 对象。它仅提供以下可变键的首次运行默认值。它不会将这些键变为不可变策略,之后的运行时更改仍保留在本地状态存储中。
发布者必须在未跟踪的 `config/config.json` 中提供已签发且非空的 `initial_state.license_key`。示例配置中的空值只支持开发构建;父级 `package_installer` 会拒绝缺失、空值或明显占位符。该值会作为初始状态嵌入二进制文件,并在系统级授权状态缺失或为空时由 Launcher 写入;已有的非空机器授权不会被构建默认值覆盖。授权不会作为可编辑的安装目录 JSON 交付。
这些值可能在不同构建之间有所不同,但安装的文件不得覆盖它们。在可执行文件旁边放置名为 `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` 保留在版本控制之外,并通过受控的构建环境提供。
* 每次发布都要在该文件中配置已签发的 `initial_state.license_key`;不要把真实授权值写入示例、文档或版本控制。
* 限制对发布构建输入的访问,但不要将客户端侧的值描述为不可提取的秘密。
* 通过明确的发布流程轮换服务器签名密钥和客户端令牌。
* 在原生后端和权限边界处诊断状态写入失败。不要使安装目录广泛可写。
* 将更改的端点、频道、可执行文件名称或启动票据密钥视为新的二进制配置,需要重新构建和包验证。
+97
View File
@@ -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` 资源、文档、第三方代码、生成的文件和构建输出。排除项必须明确,以便新添加的生产目录默认被检查。
## 运行时验证
自动目录检查是必要的但不够的。发布验收必须至少检查以下内容:
* 首次注册和凭证错误;
* 离线策略和强制更新信息;
* 更新下载、验证和回滚失败;
* 提升失败或取消;
* 直接启动拒绝;以及
* 引导程序替换失败。
请确认文本与实际对话框相符,并且切换操作系统区域设置不会改变协议行为或配置选择。
+129
View File
@@ -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 值;
* 无效或过期的签名遗留凭证;
* 原生状态写入失败后成功重试;
* 迁移完成后重复启动;以及
* 迁移后的侧车配置被篡改。
-176
View File
@@ -1,176 +0,0 @@
客户端国际化说明
================
这份文档说明 Qt 国际化文件怎么维护,以及哪些步骤是自动的、哪些步骤需要你手动做。
先看结论
========
Visual Studio 和 PowerShell 二选一即可。
- Visual Studio 是图形界面入口。
- PowerShell 是命令行入口。
- 两者最终调用的是同一套 CMake 目标,不是两套流程。
最重要的规则:
1. 普通“全部重新生成”会自动把已有的 update-client_zh_CN.ts 编译成 update-client_zh_CN.qm。
2. 普通“全部重新生成”不会自动扫描源码生成新的 update-client_zh_CN.ts 条目。
3. 如果新增了 QObject::tr(...)、QCoreApplication::translate(...) 这类新文案,必须先手动生成一次 update_client_lupdate。
4. 你编辑完 update-client_zh_CN.ts 后,再“全部重新生成”,CMake 会自动生成 .qm,并通过 qrc 打进程序。
文件说明
========
1. update-client_zh_CN.ts
翻译源文件,XML 格式。新增或修改代码里的 tr()/translate() 文案后,需要更新这个文件,再补中文翻译。
2. update-client_zh_CN.qm
Qt 运行时加载的二进制翻译文件。它由 .ts 编译生成,不要手工编辑。
3. update-client.qrc
Qt 资源文件。它会把 update-client_zh_CN.qm 编进 Launcher、Updater、Bootstrap、MainApp,不需要把 .qm 单独放到安装目录。
日常编译:没有新增界面文字
==========================
这种情况最简单。
你只是改了普通 C++ 代码,或者只是修改了 update-client_zh_CN.ts 里已有条目的中文翻译:
Visual Studio
```text
选择 x64 Release 或 x64 Debug -> 全部重新生成
```
PowerShell 等价命令:
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release
```
这时 CMake 会自动执行 lrelease
```text
update-client_zh_CN.ts -> update-client_zh_CN.qm
```
然后 .qm 会通过 update-client.qrc 打进 exe。
新增界面文字后的完整流程
========================
如果代码里新增了这些文字:
```cpp
QObject::tr("New message")
QCoreApplication::translate("Context", "New message")
```
只点“全部重新生成”是不够的。因为“全部重新生成”不会自动扫描源码,把新 source 写进 .ts。
正确流程是:
1. 先更新 .ts 文件。
Visual Studio
```text
在 CMake 目标里找到 update_client_lupdate,然后生成这个目标。
```
PowerShell 等价命令:
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release --target update_client_lupdate
```
这一步会扫描 Common、Bootstrap、Launcher、Updater、MainApp 里的 cpp/h 文件,把新增的 tr()/translate() 文案写入:
```text
i18n/update-client_zh_CN.ts
```
2. 编辑 update-client_zh_CN.ts。
找到新增的 `<source>...</source>`,把对应 `<translation>...</translation>` 补成中文。
可以用 Qt Linguist 打开,也可以直接用文本编辑器编辑 XML。
3. 再重新生成程序。
Visual Studio
```text
全部重新生成
```
PowerShell 等价命令:
```powershell
cmake --build --preset x64-release
```
这一步会自动做:
```text
update-client_zh_CN.ts -> update-client_zh_CN.qm -> update-client.qrc -> exe
```
如果只想单独生成 .qm
====================
一般不需要单独做。普通编译会自动生成 .qm。
如果你只是想检查 .ts 能不能正常编译成 .qm,可以单独生成这个目标:
Visual Studio
```text
生成 CMake 目标 update_client_translations
```
PowerShell
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release --target update_client_translations
```
常见问题
========
1. 新增了 tr(),为什么程序里没有中文?
通常是少做了 update_client_lupdate。新增文案后必须先更新 .ts,再补中文,再重新生成。
2. 我只改了 .ts 里的中文,还要跑 update_client_lupdate 吗?
不需要。直接“全部重新生成”即可,CMake 会自动重新生成 .qm。
3. update-client_zh_CN.qm 要不要交付到安装目录?
不需要。它已经通过 update-client.qrc 编进 exe。
4. 代码里能不能直接写中文?
不建议。界面文字统一写英文 source,然后在 .ts 里翻译成中文。
5. Visual Studio 或 CMake 找不到 lupdate / lrelease 怎么办?
通常是 Qt 环境变量没配好。确认 CMAKE_PREFIX_PATH 或 Qt5_DIR 指向 Qt 目录。
Windows 示例:
```powershell
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
```
Linux 示例:
```bash
sudo apt install -y qttools5-dev-tools
```
-5
View File
@@ -1,5 +0,0 @@
<RCC>
<qresource prefix="/i18n">
<file>update-client_zh_CN.qm</file>
</qresource>
</RCC>
Binary file not shown.
File diff suppressed because it is too large Load Diff
-59
View File
@@ -1,59 +0,0 @@
客户端脚本说明
==============
本目录保存 update-client 的辅助脚本。项目根目录只保留源码、CMake 入口、Docs 和配置模板,脚本统一放在这里。
脚本列表:
1. package-sdk.ps1
在 Windows 上生成给其他软件接入用的 UpdateClientSDK 包。
注意:SDK 包只面向运行接入,不包含 config/server_config.json 和 config/server_config.qrc。
服务端地址必须在打包前写入源码目录 config/server_config.json,并重新编译进 Launcher/Updater。
2. package-client.ps1
在 Windows 上生成某个具体产品的最终客户端发布包。
3. install-sdk.ps1
将 SDK 运行时复制到业务软件 Release 目录。
4. package-sdk.sh
在 Linux 上生成给其他软件接入用的 UpdateClientSDK 包,输出 tar.gz。
5. package-client.sh
在 Linux 上生成某个具体产品的最终客户端发布包,输出 tar.gz。
推荐在 update-client 根目录执行:
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
```powershell
.\scripts\package-client.ps1 `
-SourceDir .\out\bin\Release `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
Linux 示例:
```bash
bash ./scripts/package-sdk.sh \
--source-dir ./out/linux/bin \
--output-dir ./dist/UpdateClientSDK-linux \
--archive ./dist/UpdateClientSDK-linux.tar.gz \
--sdk-version 0.1.0
```
```bash
bash ./scripts/package-client.sh \
--source-dir /path/to/SimCAE \
--config-file /path/to/SimCAE/bin/config/app_config.json \
--output-dir ./dist/UpdateClient-linux \
--archive ./dist/UpdateClient-linux.tar.gz
```
-74
View File
@@ -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."
-173
View File
@@ -1,173 +0,0 @@
param(
[string]$SourceDir = "",
[Parameter(Mandatory = $true)]
[string]$ConfigFile,
[string]$OutputDir = "",
[string]$ZipFile = ""
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
}
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClient"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClient.zip"
}
$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"
}
function Get-InstallDirectoryId([string]$RuntimeDir) {
$normalized = ([IO.Path]::GetFullPath($RuntimeDir) -replace '\\', '/').TrimEnd('/')
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
$hash = $sha.ComputeHash($bytes)
return -join ($hash | ForEach-Object { $_.ToString("x2") })
} finally {
$sha.Dispose()
}
}
function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestName) {
$localData = [Environment]::GetFolderPath("LocalApplicationData")
if ([string]::IsNullOrWhiteSpace($localData)) { return "" }
$installId = Get-InstallDirectoryId $RuntimeDir
return Join-Path $localData "Marsco\UpdateClientSDK\installations\$installId\update\manifest_cache\$ManifestName"
}
$requiredFields = @(
"app_id", "channel", "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 the Release output directory 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"
$runtimeDirAbsolute = if ([string]::IsNullOrWhiteSpace($runtimeDirRelative)) {
$source
} else {
Join-Path $source ($runtimeDirRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
}
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
$manifestCandidates = @(
(Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
(Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
(Join-Path $source "update/manifest_cache/$manifestName")
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sourceManifest -or -not (Test-Path $sourceManifest)) {
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
throw "Missing signed Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging. Searched paths:$([Environment]::NewLine)$searched"
}
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"
-234
View File
@@ -1,234 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
CONFIG_FILE=""
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClient-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClient-linux.tar.gz"
SKIP_MANIFEST_CHECK=0
usage() {
cat <<'EOF'
Usage: package-client.sh --config-file FILE [options]
Options:
--source-dir DIR Release/install root to package. Default: ./out/linux/bin
--config-file FILE app_config.json used by this client package. Required.
--output-dir DIR Output directory. Default: ./dist/UpdateClient-linux
--archive FILE Output tar.gz. Default: ./dist/UpdateClient-linux.tar.gz
--skip-manifest-check Skip current-version manifest cache check.
-h, --help Show this help.
EOF
}
normalize_relative_path() {
local value="${1:-}"
value="${value//\\//}"
value="${value#/}"
value="${value%/}"
printf '%s' "$value"
}
join_relative_path() {
local base
local child
base="$(normalize_relative_path "${1:-}")"
child="$(normalize_relative_path "${2:-}")"
if [[ -z "$base" ]]; then printf '%s' "$child"; return; fi
if [[ -z "$child" ]]; then printf '%s' "$base"; return; fi
printf '%s/%s' "$base" "$child"
}
json_value() {
python3 - "$CONFIG_FILE" "$1" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8-sig") as f:
data = json.load(f)
value = data.get(sys.argv[2], "")
print("" if value is None else value)
PY
}
relative_to_source() {
local full="$1"
local fallback="$2"
python3 - "$SOURCE_DIR" "$full" "$fallback" <<'PY'
import os
import sys
source = os.path.realpath(sys.argv[1])
full = os.path.realpath(sys.argv[2])
fallback = sys.argv[3]
try:
rel = os.path.relpath(full, source)
except ValueError:
rel = fallback
if rel.startswith(".."):
rel = fallback
print(rel.replace(os.sep, "/").strip("/"))
PY
}
install_directory_id() {
python3 - "$1" <<'PY'
import hashlib
import os
import sys
value = os.path.realpath(sys.argv[1]).replace(os.sep, "/").rstrip("/")
print(hashlib.sha256(value.encode("utf-8")).hexdigest())
PY
}
user_data_manifest_candidate() {
local runtime_dir="$1"
local manifest_name="$2"
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local install_id
install_id="$(install_directory_id "$runtime_dir")"
printf '%s/Marsco/UpdateClientSDK/installations/%s/update/manifest_cache/%s' \
"$data_home" "$install_id" "$manifest_name"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--config-file) CONFIG_FILE="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--skip-manifest-check) SKIP_MANIFEST_CHECK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$CONFIG_FILE" ]]; then
echo "--config-file is required." >&2
usage >&2
exit 2
fi
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
CONFIG_FILE="$(realpath "$CONFIG_FILE")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
for field in app_id channel current_version client_token launch_token license_key main_executable launcher_executable updater_executable bootstrap_executable; do
if [[ -z "$(json_value "$field")" ]]; then
echo "Config file is missing required field: $field" >&2
exit 1
fi
done
CONFIG_RELATIVE_PATH="$(relative_to_source "$CONFIG_FILE" "config/app_config.json")"
CONFIG_RELATIVE_PARENT="$(dirname "$CONFIG_RELATIVE_PATH")"
[[ "$CONFIG_RELATIVE_PARENT" == "." ]] && CONFIG_RELATIVE_PARENT=""
RUNTIME_DIR_RELATIVE="$(normalize_relative_path "$(dirname "$CONFIG_RELATIVE_PARENT")")"
[[ "$RUNTIME_DIR_RELATIVE" == "." ]] && RUNTIME_DIR_RELATIVE=""
MAIN_EXECUTABLE="$(normalize_relative_path "$(json_value main_executable)")"
LAUNCHER_EXECUTABLE="$(normalize_relative_path "$(json_value launcher_executable)")"
UPDATER_EXECUTABLE="$(normalize_relative_path "$(json_value updater_executable)")"
BOOTSTRAP_EXECUTABLE="$(normalize_relative_path "$(json_value bootstrap_executable)")"
CURRENT_VERSION="$(json_value current_version)"
for required in \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")"; do
if [[ ! -f "$SOURCE_DIR/$required" ]]; then
echo "Source directory is missing required file: $required" >&2
exit 1
fi
done
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "Source directory contains Debug artifacts. Use a clean Release root directory." >&2
echo "Example: $DEBUG_ARTIFACT" >&2
exit 1
fi
EXPECTED_MAIN_PATH="$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")"
MAIN_LEAF="$(basename "$MAIN_EXECUTABLE")"
DUPLICATE_MAIN="$({ find "$SOURCE_DIR" -type f -name "$MAIN_LEAF" | while read -r item; do
rel="$(python3 - "$SOURCE_DIR" "$item" <<'PY'
import os, sys
print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])).replace(os.sep, "/"))
PY
)"
[[ "$rel" != "$EXPECTED_MAIN_PATH" ]] && { echo "$item"; break; }
done; } || true)"
if [[ -n "$DUPLICATE_MAIN" ]]; then
echo "Source directory contains a duplicate main executable outside $EXPECTED_MAIN_PATH: $DUPLICATE_MAIN" >&2
exit 1
fi
MANIFEST_NAME="manifest_${CURRENT_VERSION}.json"
if [[ -z "$RUNTIME_DIR_RELATIVE" ]]; then
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR"
else
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR/$RUNTIME_DIR_RELATIVE"
fi
MANIFEST_CANDIDATES=(
"$(user_data_manifest_candidate "$RUNTIME_DIR_ABSOLUTE" "$MANIFEST_NAME")"
"$SOURCE_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache/$MANIFEST_NAME")"
"$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME"
)
SOURCE_MANIFEST=""
for candidate in "${MANIFEST_CANDIDATES[@]}"; do
if [[ -f "$candidate" ]]; then
SOURCE_MANIFEST="$candidate"
break
fi
done
if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then
echo "Missing signed Manifest cache for current version: $MANIFEST_NAME." >&2
echo "Complete online update/verification for this version before packaging. Searched paths:" >&2
printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do
base="$(basename "$item")"
case "$base" in
update|update_temp) ;;
*) cp -a "$item" "$OUTPUT_DIR/" ;;
esac
done
shopt -u dotglob nullglob
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update")"
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update_temp")"
OUTPUT_CONFIG_PATH="$OUTPUT_DIR/$CONFIG_RELATIVE_PATH"
mkdir -p "$(dirname "$OUTPUT_CONFIG_PATH")"
cp "$CONFIG_FILE" "$OUTPUT_CONFIG_PATH"
rm -f "$(dirname "$OUTPUT_CONFIG_PATH")/client_identity.dat" \
"$(dirname "$OUTPUT_CONFIG_PATH")/local_state.json" \
"$(dirname "$OUTPUT_CONFIG_PATH")/version_policy.dat"
if [[ -f "$SOURCE_MANIFEST" ]]; then
MANIFEST_DIR="$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache")"
mkdir -p "$MANIFEST_DIR"
cp "$SOURCE_MANIFEST" "$MANIFEST_DIR/$MANIFEST_NAME"
fi
chmod +x "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" 2>/dev/null || true
mkdir -p "$(dirname "$ARCHIVE_FILE")"
rm -f "$ARCHIVE_FILE"
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
echo "Package directory: $OUTPUT_DIR"
echo "Archive file: $ARCHIVE_FILE"
-159
View File
@@ -1,159 +0,0 @@
param(
[string]$SourceDir = "",
[string]$OutputDir = "",
[string]$ZipFile = "",
[string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "",
[switch]$IncludeDemoMainApp,
[switch]$IncludeQtRuntime
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
}
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClientSDK"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClientSDK.zip"
}
if ([string]::IsNullOrWhiteSpace($ExampleConfig)) {
$ExampleConfig = Join-Path $RepoRoot "config/app_config.example.json"
}
$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 $RepoRoot "config/manifest_public_key.pem")
)
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) {
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key."
}
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
$_.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"
$commonDir = Join-Path $OutputDir "Common"
$docsDir = Join-Path $OutputDir "Docs"
New-Item $binDir,$configDir,$scriptsDir,$commonDir,$docsDir -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
$commonSourceDir = Join-Path $RepoRoot "Common"
$commonSourceFiles = @(
"ConfigHelper.h",
"ConfigHelper.cpp",
"TicketHelper.h",
"TicketHelper.cpp"
)
foreach ($commonFile in $commonSourceFiles) {
$commonPath = Join-Path $commonSourceDir $commonFile
if (-not (Test-Path $commonPath)) {
throw "SDK Common integration source is missing: $commonPath"
}
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
}
$docsSourceDir = Join-Path $RepoRoot "Docs"
if (Test-Path $docsSourceDir) {
Copy-Item (Join-Path $docsSourceDir "*") $docsDir -Recurse -Force
}
$wordGuideSource = @($RepoRoot, $docsSourceDir) |
Where-Object { Test-Path $_ } |
ForEach-Object { Get-ChildItem $_ -File -Filter "*.docx" } |
Where-Object { $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" } |
Sort-Object Name |
Select-Object -First 1
if ($wordGuideSource) {
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
} else {
Write-Warning "SDK Word guide is missing. Continue packaging with Markdown documents in Docs/."
}
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
docs_entry = "Docs/01-客户端接入打包部署指南.md"
word_guide_included = [bool]$wordGuideSource
integration_sources = $commonSourceFiles
} | 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"
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz"
SDK_VERSION="0.1.0"
EXAMPLE_CONFIG="$REPO_ROOT/config/app_config.linux.example.json"
INCLUDE_DEMO_MAIN_APP=0
usage() {
cat <<'EOF'
Usage: package-sdk.sh [options]
Options:
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
--output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux
--archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz
--sdk-version VERSION SDK version. Default: 0.1.0
--example-config FILE app_config template. Default: ./config/app_config.linux.example.json
--include-demo-mainapp Include MainApp demo executable in SDK bin.
-h, --help Show this help.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--sdk-version) SDK_VERSION="$2"; shift 2 ;;
--example-config) EXAMPLE_CONFIG="$2"; shift 2 ;;
--include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
EXAMPLE_CONFIG="$(realpath "$EXAMPLE_CONFIG")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
for name in Launcher Updater Bootstrap; do
if [[ ! -f "$SOURCE_DIR/$name" ]]; then
echo "SDK source directory is missing required file: $SOURCE_DIR/$name" >&2
exit 1
fi
done
PUBLIC_KEY=""
for candidate in \
"$SOURCE_DIR/config/manifest_public_key.pem" \
"$SOURCE_DIR/manifest_public_key.pem" \
"$REPO_ROOT/config/manifest_public_key.pem"; do
if [[ -f "$candidate" ]]; then
PUBLIC_KEY="$candidate"
break
fi
done
if [[ -z "$PUBLIC_KEY" ]]; then
echo "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." >&2
exit 1
fi
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2
echo "Example: $DEBUG_ARTIFACT" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common" "$OUTPUT_DIR/Docs"
shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do
base="$(basename "$item")"
case "$base" in
config|update|update_temp|manifest_public_key.pem|MainApp)
if [[ "$base" == "MainApp" && "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]]; then
cp -a "$item" "$OUTPUT_DIR/bin/"
fi
;;
*)
cp -a "$item" "$OUTPUT_DIR/bin/"
;;
esac
done
shopt -u dotglob nullglob
cp "$EXAMPLE_CONFIG" "$OUTPUT_DIR/config/app_config.json"
cp "$PUBLIC_KEY" "$OUTPUT_DIR/config/manifest_public_key.pem"
for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.cpp; do
common_path="$REPO_ROOT/Common/$common_file"
if [[ ! -f "$common_path" ]]; then
echo "SDK Common integration source is missing: $common_path" >&2
exit 1
fi
cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
done
if [[ -d "$REPO_ROOT/Docs" ]]; then
cp -a "$REPO_ROOT/Docs/." "$OUTPUT_DIR/Docs/"
fi
WORD_GUIDE="$(find "$REPO_ROOT" "$REPO_ROOT/Docs" -maxdepth 1 -type f -name '*SDK*.docx' ! -name '~$*' 2>/dev/null | sort | sed -n '1p')"
WORD_GUIDE_INCLUDED=false
if [[ -n "$WORD_GUIDE" ]]; then
cp "$WORD_GUIDE" "$OUTPUT_DIR/$(basename "$WORD_GUIDE")"
WORD_GUIDE_INCLUDED=true
else
echo "Warning: SDK Word guide is missing. Continue packaging with Markdown documents in Docs/." >&2
fi
cp "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh"
cp "$SCRIPT_DIR/package-client.sh" "$OUTPUT_DIR/scripts/package-client.sh"
if [[ -f "$SCRIPT_DIR/install-sdk.ps1" ]]; then cp "$SCRIPT_DIR/install-sdk.ps1" "$OUTPUT_DIR/scripts/install-sdk.ps1"; fi
if [[ -f "$SCRIPT_DIR/package-sdk.ps1" ]]; then cp "$SCRIPT_DIR/package-sdk.ps1" "$OUTPUT_DIR/scripts/package-sdk.ps1"; fi
if [[ -f "$SCRIPT_DIR/package-client.ps1" ]]; then cp "$SCRIPT_DIR/package-client.ps1" "$OUTPUT_DIR/scripts/package-client.ps1"; fi
chmod +x "$OUTPUT_DIR/bin/Launcher" "$OUTPUT_DIR/bin/Updater" "$OUTPUT_DIR/bin/Bootstrap" 2>/dev/null || true
chmod +x "$OUTPUT_DIR/scripts/package-sdk.sh" "$OUTPUT_DIR/scripts/package-client.sh"
cat > "$OUTPUT_DIR/sdk_manifest.json" <<EOF
{
"sdk_version": "$SDK_VERSION",
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"sdk_type": "external-updater-runtime",
"platform": "linux",
"required_entry": "Launcher",
"contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false),
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
"word_guide_included": $WORD_GUIDE_INCLUDED,
"integration_sources": [
"ConfigHelper.h",
"ConfigHelper.cpp",
"TicketHelper.h",
"TicketHelper.cpp"
]
}
EOF
mkdir -p "$(dirname "$ARCHIVE_FILE")"
rm -f "$ARCHIVE_FILE"
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
echo "SDK directory: $OUTPUT_DIR"
echo "SDK archive: $ARCHIVE_FILE"
echo "SDK version: $SDK_VERSION"
+822
View File
@@ -0,0 +1,822 @@
<?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>
<message>
<source>The signed manifest for version %1 could not be fetched or cached.</source>
<translation> %1 </translation>
</message>
<message>
<source>Version %1 is not published on the update server. Publish this exact installed build before distribution so its signed manifest can be verified.</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>