10 Commits

71 changed files with 8031 additions and 7150 deletions
+6
View File
@@ -0,0 +1,6 @@
root = true
[*]
charset = utf-8-bom
trim_trailing_whitespace = true
insert_final_newline = true
+65 -64
View File
@@ -1,72 +1,73 @@
# Operating systems and editors
.DS_Store
Thumbs.db
Desktop.ini
.vscode/
.idea/
.vs/
*.suo
*.user
*.userosscache
*.sln.docstates
# Temporary files
*.tmp
*.temp
*.bak
*.swp
*.log
*~
# Operating systems and editors
.DS_Store
Thumbs.db
Desktop.ini
.vscode/
.idea/
.vs/
*.suo
*.user
*.userosscache
*.sln.docstates
# Temporary files
*.tmp
*.temp
*.bak
*.swp
*.log
*~
# Private requirement / handover documents
*.pdf
*.doc
*.docx
# C/C++ generated artifacts
*.obj
*.o
*.pdb
*.ilk
*.idb
*.tlog
*.lastbuildstate
*.exp
*.lib
*.dll
*.exe
# Build outputs
out/
build/
build-*/
cmake-build-*/
.cmake/
CMakeFiles/
CMakeCache.txt
CMakeSettings.json
CMakeUserPresets.json
Testing/
private/
pdf_requirements.txt
# C/C++ generated artifacts
*.obj
*.o
*.pdb
*.ilk
*.idb
*.tlog
*.lastbuildstate
*.exp
*.lib
*.dll
*.exe
# Build outputs
out/
build/
build-*/
cmake-build-*/
.cmake/
CMakeFiles/
CMakeCache.txt
CMakeSettings.json
CMakeUserPresets.json
Testing/
# Third-party/business binary drops and generated packages
thirdparty/
App/
dist/
*.zip
*.tar
*.tar.gz
*.tgz
*.7z
*.rar
# Local runtime state and credentials
client.ini
config/config.json
config/app_config.json
config/client_identity.dat
config/local_state.json
config/version_policy.dat
config/*.local.json
config/*private*.pem
config/*private*.key
update/
update_temp/
*.tar
*.tar.gz
*.tgz
*.7z
*.rar
# Local runtime state and credentials
config/app_config.json
config/client_identity.dat
config/local_state.json
config/version_policy.dat
config/*.local.json
config/*private*.pem
config/*private*.key
update/
update_temp/
+14 -13
View File
@@ -1,19 +1,20 @@
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()
project(Bootstrap LANGUAGES CXX)
add_executable(Bootstrap ${_bootstrap_sources})
target_compile_features(Bootstrap PRIVATE cxx_std_17)
set_target_properties(Bootstrap PROPERTIES
AUTOMOC OFF
AUTOUIC OFF
AUTORCC OFF
add_executable(Bootstrap
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_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets)
if(WIN32)
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
target_link_libraries(Bootstrap PRIVATE shell32)
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 Bootstrap POST_BUILD
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Bootstrap>
COMMENT "Deploy Qt runtime for Bootstrap"
)
endif()
-28
View File
@@ -1,28 +0,0 @@
#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
+152 -157
View File
@@ -1,208 +1,203 @@
#include <windows.h>
#include <shellapi.h>
#include "resource.h"
#include <filesystem>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
#include <QApplication>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMessageBox>
#include <QProcess>
#include <QTextStream>
#include <QThread>
#include <QTranslator>
#include <QVector>
namespace fs = std::filesystem;
static std::wstring loadLocalizedString(unsigned int id)
struct PlanItem
{
const wchar_t* value = nullptr;
const int length = LoadStringW(GetModuleHandleW(nullptr), id,
reinterpret_cast<LPWSTR>(&value), 0);
return length > 0 ? std::wstring(value, static_cast<size_t>(length)) : std::wstring();
QChar operation;
QString relativePath;
};
static void showError(const QString& message)
{
QMessageBox::critical(nullptr,
QApplication::translate("Bootstrap", "Update Handoff Failed"),
message);
}
static void showBootstrapError(unsigned int messageId)
static QString cleanRelativePath(const QString& value)
{
const std::wstring message = loadLocalizedString(messageId);
const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
}
QString path = QDir::fromNativeSeparators(value.trimmed());
if (path.isEmpty())
return {};
static std::wstring quote(const std::wstring& value)
{
std::wstring result = L"\"";
unsigned backslashes = 0;
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;
}
result.append(backslashes, L'\\');
backslashes = 0;
result.push_back(ch);
path = QDir::cleanPath(path);
if (path == "." || path == ".." || path.startsWith("../")
|| path.contains("/../") || QDir::isAbsolutePath(path)
|| path.contains(':')) {
return {};
}
result.append(backslashes * 2, L'\\');
result.push_back(L'\"');
return result;
return path;
}
static std::wstring fromUtf8(const std::string& value)
static QString joinPath(const QString& root, const QString& relativePath)
{
if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0);
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;
return QDir(root).filePath(relativePath);
}
static bool safeRelative(const fs::path& path)
static bool removeWithRetry(const QString& 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;
}
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
{
std::error_code ec;
fs::create_directories(destination.parent_path(), ec);
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
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));
if (!QFileInfo::exists(path))
return true;
if (QFile::remove(path))
return true;
QThread::msleep(100);
}
return !QFileInfo::exists(path);
}
static bool copyWithRetry(const QString& source, const QString& destination)
{
QDir().mkpath(QFileInfo(destination).absolutePath());
for (int i = 0; i < 100; ++i) {
QFile::remove(destination);
if (QFile::copy(source, destination))
return true;
QThread::msleep(100);
}
return false;
}
static bool removeWithRetry(const fs::path& path)
{
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)
static bool rollback(const QString& installDir, const QString& backupDir,
const QVector<QString>& paths)
{
bool success = true;
for (const auto& relative : paths) {
const fs::path destination = installDir / relative;
const fs::path backup = backupDir / relative;
std::error_code ec;
if (fs::exists(backup, ec)) {
if (!copyWithRetry(backup, destination)) success = false;
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
for (const QString& relativePath : paths) {
const QString destination = joinPath(installDir, relativePath);
const QString backup = joinPath(backupDir, relativePath);
if (QFileInfo::exists(backup)) {
if (!copyWithRetry(backup, destination))
success = false;
} else if (QFileInfo::exists(destination) && !removeWithRetry(destination)) {
success = false;
}
}
return success;
}
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
const std::wstring& result)
static bool launchUpdater(const QString& updater, const QStringList& updateArgs,
const QString& result)
{
std::wstring command = quote(updater);
for (const auto& arg : updateArgs) command += L" " + quote(arg);
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;
QStringList args = updateArgs;
args.append(QStringLiteral("--bootstrap-resume=%1").arg(result));
return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath());
}
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths)
{
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!argv || argc < 11) {
showBootstrapError(IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE);
if (argv) LocalFree(argv);
QFile file(planFile);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return false;
QTextStream input(&file);
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;
}
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);
if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
WaitForSingleObject(process, 30000);
CloseHandle(process);
const QString planFile = arguments.at(1);
const QString installDir = arguments.at(2);
const QString stagingDir = arguments.at(3);
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);
QVector<PlanItem> items;
QVector<QString> paths;
if (!readPlan(planFile, &items, &paths)) {
showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid."));
return 3;
}
struct PlanItem { wchar_t operation; fs::path relative; };
std::ifstream input(planFile, std::ios::binary);
std::vector<PlanItem> items;
std::vector<fs::path> paths;
std::string line;
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;
}
const fs::path relative(fromUtf8(line.substr(2)));
if (!safeRelative(relative)) {
showBootstrapError(IDS_BOOTSTRAP_UNSAFE_PLAN_PATH);
return 3;
}
items.push_back({static_cast<wchar_t>(line[0]), relative});
paths.push_back(relative);
}
bool success = input.eof();
bool success = true;
bool rolledBack = false;
fs::path failedPath;
if (mode == L"rollback") {
rolledBack = success && rollback(installDir, backupDir, paths);
QString failedPath;
if (mode == QStringLiteral("rollback")) {
rolledBack = 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) {
} else {
// Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
// 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
for (const PlanItem& item : items) {
const QString& relativePath = item.relativePath;
if (isBootstrapSelfPath(relativePath)) {
success = false;
failedPath = relative;
failedPath = relativePath;
break;
}
const bool itemOk = item.operation == L'C'
? copyWithRetry(stagingDir / relative, installDir / relative)
: removeWithRetry(installDir / relative);
const bool itemOk = item.operation == QLatin1Char('C')
? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath))
: removeWithRetry(joinPath(installDir, relativePath));
if (!itemOk) {
success = false;
failedPath = relative;
failedPath = relativePath;
break;
}
}
if (!success) rolledBack = rollback(installDir, backupDir, paths);
if (!success)
rolledBack = rollback(installDir, backupDir, paths);
}
const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
const QString result = success ? QStringLiteral("success")
: (rolledBack ? QStringLiteral("rolledback") : QStringLiteral("rollback-failed"));
if (!launchUpdater(updater, updateArgs, result)) {
std::wstring message = loadLocalizedString(IDS_BOOTSTRAP_RESTART_UPDATER_FAILED);
if (!failedPath.empty())
message += L"\n" + loadLocalizedString(IDS_BOOTSTRAP_FAILED_FILE_PREFIX) + failedPath.wstring();
const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
QString message = QApplication::translate("Bootstrap", "Cannot restart Updater.");
if (!failedPath.isEmpty()) {
message += QApplication::translate("Bootstrap", "\nFailed file: %1")
.arg(failedPath);
}
showError(message);
return 4;
}
return (success || rolledBack) ? 0 : 5;
-8
View File
@@ -1,8 +0,0 @@
#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
+131 -136
View File
@@ -1,166 +1,161 @@
cmake_minimum_required(VERSION 3.20)
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()
project(ClientAll LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
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_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
if(MSVC)
add_compile_options(/utf-8)
endif()
get_target_property(QT_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION)
get_filename_component(QT_BIN_DIR "${QT_QMAKE_EXECUTABLE}" DIRECTORY)
find_program(QT_LRELEASE_EXECUTABLE NAMES lrelease lrelease.exe HINTS "${QT_BIN_DIR}" REQUIRED)
find_program(QT_LUPDATE_EXECUTABLE NAMES lupdate lupdate.exe HINTS "${QT_BIN_DIR}" REQUIRED)
cmake_path(
ABSOLUTE_PATH UPDATE_CLIENT_SOURCE_DIR
BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/.."
NORMALIZE
OUTPUT_VARIABLE _update_client_source_root
)
cmake_path(GET _update_client_source_root PARENT_PATH _update_client_parent_root)
set(
UPDATE_CLIENT_THIRDPARTY_ROOT
"${_update_client_parent_root}/3rdparty"
CACHE PATH
"Root directory for update-client third-party packages"
)
option(
UPDATE_CLIENT_STRICT_THIRDPARTY
"Require OpenSSL imported library and include paths to stay under the third-party root"
ON
)
set(
UPDATE_CLIENT_PRODUCT_CONFIG_FILE
""
CACHE FILEPATH
"Exact product config to embed; empty selects config/config.json then the example"
)
set(
UPDATE_CLIENT_LAUNCH_TOKEN
""
CACHE STRING
"Optional build-provided launch ticket token override"
)
mark_as_advanced(UPDATE_CLIENT_LAUNCH_TOKEN)
set(UPDATE_CLIENT_MAIN_EXECUTABLE "" CACHE STRING "Override main_executable in embedded config")
set(UPDATE_CLIENT_LAUNCHER_EXECUTABLE "" CACHE STRING "Override launcher_executable in embedded config")
set(UPDATE_CLIENT_UPDATER_EXECUTABLE "" CACHE STRING "Override updater_executable in embedded config")
set(UPDATE_CLIENT_BOOTSTRAP_EXECUTABLE "" CACHE STRING "Override bootstrap_executable in embedded config")
set(UPDATE_CLIENT_PLATFORM "" CACHE STRING "Override platform in embedded config")
set(UPDATE_CLIENT_ARCH "" CACHE STRING "Override arch in embedded config")
if(WIN32)
set(_update_client_deploy_qt_default ON)
else()
set(_update_client_deploy_qt_default OFF)
endif()
option(
UPDATE_CLIENT_DEPLOY_QT_RUNTIME
"Run the Qt deployment tool after building each Qt executable"
${_update_client_deploy_qt_default}
)
option(
UPDATE_CLIENT_REQUIRE_TRANSLATIONS
"Fail configuration when translations/update-client_zh_CN.ts is absent"
OFF
)
option(
UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS
"Install update-client runtime assets from this project"
${UPDATE_CLIENT_IS_STANDALONE}
)
option(
UPDATE_CLIENT_BUILD_DEMO_APP
"Build the standalone MainApp integration demo"
${UPDATE_CLIENT_IS_STANDALONE}
set(TRANSLATION_TS "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.ts")
set(TRANSLATION_QM "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.qm")
set(TRANSLATION_SCAN_DIRS
"${CMAKE_SOURCE_DIR}/Common"
"${CMAKE_SOURCE_DIR}/Bootstrap"
"${CMAKE_SOURCE_DIR}/Launcher"
"${CMAKE_SOURCE_DIR}/Updater"
"${CMAKE_SOURCE_DIR}/MainApp"
)
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"
add_custom_command(
OUTPUT "${TRANSLATION_QM}"
COMMAND "${QT_LRELEASE_EXECUTABLE}" "${TRANSLATION_TS}" -qm "${TRANSLATION_QM}"
DEPENDS "${TRANSLATION_TS}"
COMMENT "Compile Qt translation: update-client_zh_CN.qm"
VERBATIM
)
add_dependencies(UpdateClientEmbeddedResources UpdateClientNoHanCheck)
add_custom_target(update_client_translations ALL
DEPENDS "${TRANSLATION_QM}"
)
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")
add_custom_target(update_client_lupdate
COMMAND "${QT_LUPDATE_EXECUTABLE}"
${TRANSLATION_SCAN_DIRS}
-extensions cpp,h
-no-obsolete
-ts "${TRANSLATION_TS}"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Update Qt translation source: update-client_zh_CN.ts"
VERBATIM
)
# 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)
set(SIMCAE_OPENSSL_ROOT "${THIRDPARTY_DIR}/OpenSSL-Win64" CACHE PATH "OpenSSL Win64 root")
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()
find_package(OpenSSL REQUIRED)
set(OPENSSL_INC "${OPENSSL_INCLUDE_DIR}")
set(SIMCAE_OPENSSL_LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto)
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(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(Updater)
add_subdirectory(MainApp)
set(_update_client_primary_targets
Bootstrap
Common
UpdateClientUpdaterLogic
Launcher
Updater
)
if(UPDATE_CLIENT_BUILD_DEMO_APP)
add_subdirectory(MainApp)
list(APPEND _update_client_primary_targets MainApp)
# 默认启动项目
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# Copy config templates and static resources to output bin folder.
if(UNIX AND NOT APPLE AND EXISTS "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json")
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json")
else()
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
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}")
foreach(_update_client_primary_target IN LISTS _update_client_primary_targets)
add_dependencies("${_update_client_primary_target}" UpdateClientNoHanCheck)
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
endif()
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
if(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()
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)
if(UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS)
update_client_install_openssl_runtime(bin)
# Install rules for packaging
if(EXISTS "${APP_CONFIG_SOURCE_FILE}")
install(FILES "${APP_CONFIG_SOURCE_FILE}" DESTINATION bin/config RENAME app_config.json)
endif()
if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
endif()
+99
View File
@@ -0,0 +1,99 @@
{
"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"
}
]
}
+34 -34
View File
@@ -1,36 +1,36 @@
set(_common_sources
HttpHelper.h
HttpHelper.cpp
FileHelper.h
FileHelper.cpp
ConfigHelper.h
ConfigHelper.cpp
InitialStatePolicy.h
ManifestBootstrapPolicy.h
RuntimeProtectionPolicy.h
PolicyHelper.h
PolicyHelper.cpp
LocalStateHelper.h
LocalStateHelper.cpp
TicketHelper.h
TicketHelper.cpp
IntegrityHelper.h
IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
TranslationHelper.h
TranslationHelper.cpp
project(Common LANGUAGES C CXX)
set(SRC
HttpHelper.h
HttpHelper.cpp
FileHelper.h
FileHelper.cpp
ConfigHelper.h
ConfigHelper.cpp
PolicyHelper.h
PolicyHelper.cpp
LocalStateHelper.h
LocalStateHelper.cpp
TicketHelper.h
TicketHelper.cpp
IntegrityHelper.h
IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
)
add_library(Common STATIC ${SRC})
# Common编译自身需要OpenSSL头文件
target_include_directories(Common
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${OPENSSL_INC}
)
target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
PUBLIC ${SIMCAE_OPENSSL_LINK_LIBRARIES}
)
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
PUBLIC
Qt5::Core
Qt5::Network
Qt5::Widgets
OpenSSL::SSL
OpenSSL::Crypto
)
if(WIN32)
target_link_libraries(Common INTERFACE shell32)
endif()
+779 -569
View File
File diff suppressed because it is too large Load Diff
+27 -17
View File
@@ -1,35 +1,45 @@
#pragma once
#include <QJsonObject>
#include <QByteArray>
#include <QString>
class QSettings;
class ConfigHelper
{
public:
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;
bool setValue(const QString& section, const QString& key, const QString& value);
QString configPath() const;
QString productConfigPath() const;
QString stateLocation() const;
bool setValue(const QString& section, const QString& key, const QString& value);
QString configPath() const;
QString installRoot() const;
QString runtimeRoot() const;
QString dataRoot() const;
QString dataConfigDir() const;
QString clientIdentityPath() const;
QString policyPath() const;
QString localStatePath() const;
QString updateRoot() const;
QString runtimeRelativePath() const;
QString lastError() const;
private:
ConfigHelper();
bool loadProductConfig();
bool migrateLegacyStateIfNeeded();
bool initializeStateDefaults();
QString stateStorageKey(const QString& key) const;
QString stateValue(const QString& key) const;
bool writeStateValue(const QString& key, const QString& value);
static bool isMutableKey(const QString& key);
QString m_productConfigPath;
QString m_statePrefix;
QJsonObject m_productConfig;
QJsonObject m_initialState;
bool migrateLegacyIniIfNeeded();
void enterRegistryGroup(QSettings& settings) const;
bool syncRegistryFromConfigFileIfChanged();
bool sanitizeConfigFileAfterImport(QSettings& settings);
bool removeRegistryValue(const QString& key) const;
QString readEmbeddedValue(const QString& key) const;
bool readRegistryValue(const QString& key, QString* value) const;
bool writeRegistryValue(const QString& key, const QString& value);
QString readFileValue(const QString& key) const;
QString m_configPath;
QString m_registryInstallId;
QString m_error;
};
+193 -236
View File
@@ -1,19 +1,18 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QTimer>
#include <QUuid>
@@ -23,18 +22,16 @@
#include <openssl/pem.h>
#endif
namespace
namespace {
QString manifestPublicKeyPath(const QString &installDir)
{
QString machineHash(const QString& installationId)
{
const QByteArray identity =
QSysInfo::machineUniqueId() + installationId.toUtf8();
return QString::fromLatin1(
QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex());
}
return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem"));
}
DeviceIdentityHelper::DeviceIdentityHelper(const QString& installDir)
} // namespace
DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir)
: m_installDir(installDir)
{
}
@@ -49,316 +46,276 @@ QString DeviceIdentityHelper::errorString() const
return m_error;
}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,
const QString& signatureBase64)
bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64)
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = "OpenSSL unavailable";
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"OpenSSL is unavailable, so device credential signature cannot be verified.");
return false;
#else
QFile keyFile(
QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly))
{
m_error = "embedded device public key missing";
const QString keyPath = manifestPublicKeyPath(m_installDir);
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio
? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)
: nullptr;
if (bio)
BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) {
BIO_free(bio);
if (!key)
{
m_error = "device public key invalid";
}
if (!publicKey) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath);
return false;
}
EVP_MD_CTX* context = EVP_MD_CTX_new();
const QByteArray signature =
QByteArray::fromBase64(signatureBase64.toUtf8());
const bool valid =
context
&& EVP_DigestVerifyInit(
context, nullptr, EVP_sha256(), nullptr, key)
== 1
&& EVP_DigestVerifyUpdate(
context, payload.constData(), payload.size())
== 1
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx
&& EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(
context,
reinterpret_cast<const unsigned char*>(signature.constData()),
signature.size())
== 1;
ctx,
reinterpret_cast<const unsigned char *>(signature.constData()),
signature.size()) == 1;
if (context)
EVP_MD_CTX_free(context);
EVP_PKEY_free(key);
if (!valid)
m_error = "device credential RSA signature invalid";
return valid;
if (ctx) {
EVP_MD_CTX_free(ctx);
}
EVP_PKEY_free(publicKey);
if (!ok) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential signature is invalid. The local identity file may not match this server.");
}
return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& expectedAppId,
const QString& expectedChannel)
bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel)
{
QFile credential(
QDir(m_installDir).filePath("config/client_identity.dat"));
if (!credential.open(QIODevice::ReadOnly))
{
m_error = "device credential is missing or unreadable";
// client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。
// 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。
QString credentialPath = ConfigHelper::instance().clientIdentityPath();
if (!QFile::exists(credentialPath)) {
credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat"));
}
QFile credentialFile(credentialPath);
if (!credentialFile.open(QIODevice::ReadOnly)) {
return false;
}
QJsonParseError wrapperError;
const QJsonDocument wrapperDocument =
QJsonDocument::fromJson(credential.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError
|| !wrapperDocument.isObject())
{
m_error = "device credential JSON invalid";
QJsonParseError parseError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1")
.arg(credentialPath);
return false;
}
const QJsonObject wrapper = wrapperDocument.object();
const QByteArray identityText =
wrapper.value("identity_text").toString().toUtf8();
if (identityText.isEmpty()
|| !verifySignature(
identityText, wrapper.value("signature").toString()))
{
return false;
}
QJsonParseError identityError;
const QJsonDocument identityDocument =
QJsonDocument::fromJson(identityText, &identityError);
if (identityError.error != QJsonParseError::NoError
|| !identityDocument.isObject())
{
m_error = "signed device identity JSON invalid";
return false;
}
const QJsonObject identity = identityDocument.object();
const QString signedInstallationId =
identity.value("installation_id").toString();
const QString signedDeviceId = identity.value("device_id").toString();
ConfigHelper& config = ConfigHelper::instance();
const QString localInstallationId =
config.getValue("Device", "installation_id").trimmed();
const QString localDeviceId =
config.getValue("Update", "device_id").trimmed();
if (identity.value("app_id").toString() != expectedAppId
|| identity.value("channel").toString() != expectedChannel
|| identity.value("license_id").toString().isEmpty()
|| signedInstallationId.isEmpty() || signedDeviceId.isEmpty())
{
m_error = "device/license credential identity mismatch";
return false;
}
if (localInstallationId.isEmpty()
|| signedInstallationId != localInstallationId)
{
m_error = "device credential belongs to another installation";
return false;
}
if (!localDeviceId.isEmpty() && signedDeviceId != localDeviceId)
{
m_error = "device credential does not match the registered device";
return false;
}
const QString signedMachineHash =
identity.value("machine_hash").toString();
if (!signedMachineHash.isEmpty()
&& signedMachineHash.compare(
machineHash(localInstallationId), Qt::CaseInsensitive)
!= 0)
{
m_error = "device credential belongs to another machine";
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8();
const QString signature = wrapper.value(QStringLiteral("signature")).toString();
if (identityText.isEmpty() || !verifySignature(identityText, signature)) {
return false;
}
const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText);
const QJsonObject identity = identityDoc.object();
const QDateTime expiry = QDateTime::fromString(
identity.value("valid_until").toString(), Qt::ISODate);
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc())
{
m_error = "license expired";
identity.value(QStringLiteral("valid_until")).toString(),
Qt::ISODate);
const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId
&& identity.value(QStringLiteral("channel")).toString() == expectedChannel
&& !identity.value(QStringLiteral("license_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("installation_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("device_id")).toString().isEmpty();
if (!identityMatches) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential does not match this application, channel, license, installation or device.");
return false;
}
m_deviceId = signedDeviceId;
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License has expired. Please ask the administrator to issue a new License.");
return false;
}
m_deviceId = identity.value(QStringLiteral("device_id")).toString();
return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString& appId,
const QString& channel)
bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel)
{
m_error.clear();
m_deviceId.clear();
return loadAndVerify(appId, channel);
}
bool DeviceIdentityHelper::ensureIssued(const QString& apiBaseUrl,
const QString& clientToken,
const QString& appId,
const QString& channel,
const QString& licenseKey)
bool DeviceIdentityHelper::ensureIssued(
const QString &apiBaseUrl,
const QString &clientToken,
const QString &appId,
const QString &channel,
const QString &licenseKey)
{
// 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。
// 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。
m_error.clear();
m_deviceId.clear();
ConfigHelper& config = ConfigHelper::instance();
if (loadAndVerify(appId, channel))
{
if (!config.setValue("Update", "device_id", m_deviceId))
{
m_error = QString("cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
if (loadAndVerify(appId, channel)) {
ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId);
return true;
}
const QString server = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty())
{
m_error = "app_id is empty in the embedded product configuration";
const QString trimmedBaseUrl = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json.");
return false;
}
if (channel.trimmed().isEmpty())
{
m_error = "channel is empty in the embedded product configuration";
if (channel.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json.");
return false;
}
if (server.isEmpty()
|| server.contains("YOUR_SERVER_IP", Qt::CaseInsensitive))
{
m_error = "api_base_url is not configured";
if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000");
return false;
}
if (clientToken.trimmed().isEmpty())
{
m_error = "client_token is empty in the embedded product configuration";
if (clientToken.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"client_token is empty. Copy the client_token generated by the admin page into app_config.json.");
return false;
}
if (licenseKey.trimmed().isEmpty())
{
m_error = "license_key is empty";
if (licenseKey.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License is empty. Create or select a License in the admin page, then copy the generated client configuration.");
return false;
}
QString installationId =
config.getValue("Device", "installation_id").trimmed();
if (installationId.isEmpty())
{
installationId =
QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!config.setValue("Device", "installation_id", installationId))
{
m_error = QString("cannot save installation id to %1: %2")
ConfigHelper &config = ConfigHelper::instance();
QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
if (installationId.isEmpty()) {
installationId = QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
}
const QJsonObject requestBody{
{"app_id", appId},
{"channel", channel},
{"license_key", licenseKey},
{"installation_id", installationId},
{"machine_hash", machineHash(installationId)}};
QNetworkAccessManager manager;
QNetworkRequest request{QUrl(server + "/api/v1/device/issue")};
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply* reply = manager.post(
request, QJsonDocument(requestBody).toJson(QJsonDocument::Compact));
const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8();
const QString machineHash = QString::fromLatin1(
QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex());
const QJsonObject body{
{QStringLiteral("app_id"), appId},
{QStringLiteral("channel"), channel},
{QStringLiteral("license_key"), licenseKey},
{QStringLiteral("installation_id"), installationId},
{QStringLiteral("machine_hash"), machineHash},
};
QNetworkAccessManager manager;
QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
QEventLoop loop;
bool timeoutValid = false;
int timeoutMs =
config.getValue("Update", "request_timeout_ms").toInt(&timeoutValid);
if (!timeoutValid || timeoutMs < 1000)
timeoutMs = 5000;
QTimer timer;
timer.setSingleShot(true);
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance()
.getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms"))
.toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) {
timeoutMs = 5000;
}
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning())
if (reply && reply->isRunning()) {
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished,
&loop, &QEventLoop::quit);
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
const int status =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray responseBytes = reply->readAll();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString networkError = reply->errorString();
const QByteArray raw = reply->readAll();
reply->deleteLater();
if (status != 200)
{
m_error = QString("device issue failed (HTTP %1): %2 %3")
.arg(status)
.arg(networkError, QString::fromUtf8(responseBytes));
if (status != 200) {
QJsonParseError responseError;
const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError);
QString serverMessage;
if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) {
const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail"));
serverMessage = detail.isObject()
? detail.toObject().value(QStringLiteral("msg")).toString()
: detail.toString();
}
if (serverMessage.isEmpty())
serverMessage = QString::fromUtf8(raw).trimmed();
if (status == 0) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms")
.arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs));
} else {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5")
.arg(trimmedBaseUrl, QString::number(status), appId, channel,
serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "<empty response>") : serverMessage);
}
return false;
}
QJsonParseError responseError;
const QJsonDocument responseDocument =
QJsonDocument::fromJson(responseBytes, &responseError);
if (responseError.error != QJsonParseError::NoError
|| !responseDocument.isObject())
{
m_error = "device issue response is invalid JSON";
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 response = responseDocument.object();
const QJsonObject wrapper{
{"identity_text", response.value("identity_text")},
{"signature", response.value("signature")}};
if (!wrapper.value("identity_text").isString()
|| wrapper.value("identity_text").toString().isEmpty()
|| !wrapper.value("signature").isString()
|| wrapper.value("signature").toString().isEmpty())
{
m_error = "device issue response is missing signed identity fields";
return false;
}
{QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))},
{QStringLiteral("signature"), response.value(QStringLiteral("signature"))},
};
const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
const QString credentialPath = config.clientIdentityPath();
const QString credentialPath =
QDir(m_installDir).filePath("config/client_identity.dat");
if (!QDir().mkpath(QFileInfo(credentialPath).path()))
{
m_error = "cannot create the device credential directory";
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2")
.arg(credentialPath, writeError);
return false;
}
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());
if (!loadAndVerify(appId, channel)) {
return false;
}
if (!loadAndVerify(appId, channel))
return false;
if (!config.setValue("Update", "device_id", m_deviceId))
{
m_error = QString("cannot save server device id to %1: %2")
if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
+22 -9
View File
@@ -1,15 +1,28 @@
#pragma once
#include <QByteArray>
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const;
QString errorString() const;
explicit DeviceIdentityHelper(const QString &installDir);
bool ensureIssued(
const QString &apiBaseUrl,
const QString &clientToken,
const QString &appId,
const QString &channel,
const QString &licenseKey);
bool verifyLocal(const QString &appId, const QString &channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error;
bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel);
bool verifySignature(const QByteArray &payload, const QString &signatureBase64);
QString m_installDir;
QString m_deviceId;
QString m_error;
};
+79 -24
View File
@@ -1,5 +1,20 @@
#include "FileHelper.h"
#include <QDebug>
#include "FileHelper.h"
#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)
{
@@ -13,30 +28,70 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
{
if (QFile::exists(dst))
{
if (!QFile::remove(dst))
{
qDebug() << "Cannot remove the old file:" << dst;
return false;
}
if (!QFile::remove(dst))
{
qDebug() << "Cannot remove old file:" << dst;
return false;
}
}
return QFile::copy(src, dst);
}
bool FileHelper::isProcessRunning(const QString &exeName)
{
QProcess process;
process.start("tasklist");
process.waitForFinished();
QString output = process.readAllStandardOutput();
return output.contains(exeName, Qt::CaseInsensitive);
}
bool FileHelper::killProcess(const QString &exeName)
{
if (!isProcessRunning(exeName))
return true;
QProcess process;
process.start("taskkill /f /im " + exeName);
process.waitForFinished(1000);
return !isProcessRunning(exeName);
bool FileHelper::isProcessRunning(const QString &exeName)
{
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return false;
QProcess process;
#ifdef Q_OS_WIN
process.start(QStringLiteral("tasklist"), QStringList{
QStringLiteral("/FI"),
QStringLiteral("IMAGENAME eq %1").arg(imageName)
});
process.waitForFinished();
const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
return output.contains(imageName, 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)
{
if (!isProcessRunning(exeName))
return true;
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return true;
QProcess process;
#ifdef Q_OS_WIN
process.start(QStringLiteral("taskkill"), QStringList{
QStringLiteral("/f"),
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;
}
+70 -67
View File
@@ -1,67 +1,70 @@
#include "HttpHelper.h"
#include <QNetworkProxy>
#include "ConfigHelper.h"
#include <QFile>
#include <QDir>
#include <QApplication>
#include <QTimer>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
QEventLoop loop;
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
qDebug() << "Request timeout, abort:" << url;
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
int retCode = 0;
QJsonObject retObj;
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
retObj = QJsonDocument::fromJson(respData).object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
}
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
}
#include "HttpHelper.h"
#include <QNetworkProxy>
#include "ConfigHelper.h"
#include <QFile>
#include <QDir>
#include <QApplication>
#include <QTimer>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
QString identityPath = ConfigHelper::instance().clientIdentityPath();
if (!QFile::exists(identityPath))
identityPath = QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat");
QFile identity(identityPath);
if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
QEventLoop loop;
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
qDebug() << "Request timeout, abort:" << url;
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
int retCode = 0;
QJsonObject retObj;
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
retObj = QJsonDocument::fromJson(respData).object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
}
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
}
+1 -1
View File
@@ -11,7 +11,7 @@
class HttpHelper
{
public:
// Each request owns its manager so synchronous calls cannot share reply state.
// Create an independent manager for each call instead of keeping it as a member.
static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback);
};
-19
View File
@@ -1,19 +0,0 @@
#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();
}
}
+177 -110
View File
@@ -1,149 +1,216 @@
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include "RuntimeProtectionPolicy.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {}
QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::isManifestCacheMissing() const
{
return m_manifestCacheMissing;
}
bool IntegrityHelper::safeRelativePath(const QString& path) const
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
}
#include <QJsonObject>
#include <QSet>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {}
QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::safeRelativePath(const QString& path) const
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
}
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{
return UpdateClient::isRuntimeProtectedPath(
path, ConfigHelper::instance().runtimeRelativePath());
}
QString IntegrityHelper::sha256(const QString& filePath) const
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd()) hash.addData(file.read(1024 * 1024));
return QString::fromLatin1(hash.result().toHex());
}
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
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
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd()) hash.addData(file.read(1024 * 1024));
return QString::fromLatin1(hash.result().toHex());
}
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
Q_UNUSED(payload); Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
return false;
#else
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "embedded manifest public key missing"; return false; }
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) { m_error = "manifest public key invalid"; return false; }
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) m_error = "manifest RSA signature invalid";
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
if (!QFile::exists(keyPath))
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = 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();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) {
m_error = 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();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) {
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;
#endif
}
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version)
{
m_error.clear();
m_manifestCacheMissing = false;
// Manifest cache 来自服务端发布版本时生成的签名清单。
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
"manifest_cache/manifest_" + version + ".json");
const QString legacyCachePath = QDir(m_installDir).filePath(
"update/manifest_cache/manifest_" + version + ".json");
if (!QFile::exists(cachePath))
cachePath = legacyCachePath;
if (!QFile::exists(cachePath)) {
m_manifestCacheMissing = true;
m_error = "signed manifest cache missing for " + version;
"manifest_cache/manifest_" + version + ".json");
const QString legacyCachePath = QDir(m_installDir).filePath(
"update/manifest_cache/manifest_" + version + ".json");
if (!QFile::exists(cachePath))
cachePath = legacyCachePath;
QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"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;
}
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);
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = "manifest cache JSON invalid"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"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 QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false;
QJsonParseError manifestError;
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty()) {
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;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
m_error = "signed manifest payload invalid"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"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();
if (manifest.value("app_id").toString() != appId
const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = "manifest identity does not match local application"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) {
QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; }
if (!safeRelativePath(path)) {
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;
const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; }
if (!QFile::exists(fullPath)) {
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 actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
m_error = "file hash mismatch: " + path; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"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();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
}
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
}
QDir root(m_installDir);
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
// 这能降低被人偷偷塞插件或可执行文件的风险。
while (it.hasNext()) {
const QString fullPath = it.next();
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
const QString folded = relative.toCaseFolded();
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
const bool runtimeWorkDir = !runtimePrefix.isEmpty()
&& (folded.startsWith(runtimePrefix + "/update/")
|| folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString fullPath = it.next();
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
const QString folded = relative.toCaseFolded();
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
const bool runtimeWorkDir = !runtimePrefix.isEmpty()
&& (folded.startsWith(runtimePrefix + "/update/")
|| folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = "undeclared executable or plugin: " + relative; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
return false;
}
}
return true;
}
}
return true;
}
+20 -22
View File
@@ -1,22 +1,20 @@
#pragma once
#include <QString>
class IntegrityHelper
{
public:
explicit IntegrityHelper(const QString& installDir);
bool verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version);
bool isManifestCacheMissing() const;
QString errorString() const;
private:
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
bool safeRelativePath(const QString& path) const;
bool runtimeProtectedPath(const QString& path) const;
QString sha256(const QString& filePath) const;
QString m_installDir;
QString m_error;
bool m_manifestCacheMissing = false;
};
#pragma once
#include <QString>
class IntegrityHelper
{
public:
explicit IntegrityHelper(const QString& installDir);
bool verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version);
QString errorString() const;
private:
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
bool safeRelativePath(const QString& path) const;
bool runtimeProtectedPath(const QString& path) const;
QString sha256(const QString& filePath) const;
QString m_installDir;
QString m_error;
};
+125 -109
View File
@@ -1,147 +1,163 @@
#include "LocalStateHelper.h"
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
LocalStateHelper::LocalStateHelper(const QString& baseDir)
: m_baseDir(baseDir)
, m_filePath(baseDir + "/config/local_state.json")
, m_filePath(ConfigHelper::instance().localStatePath())
{
}
bool LocalStateHelper::loadState(const QString& relativePath)
{
m_filePath = m_baseDir + "/" + 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;
QFile file(m_filePath);
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{
{"max_policy_seq", 0},
{"last_success_run_at", QString()},
{"last_online_verified_at", QString()},
{"last_success_version", QString()}
};
m_loaded = true;
{"last_online_verified_at", QString()},
{"last_success_version", QString()}
};
m_loaded = true;
if (!saveState())
{
m_error = QString("Cannot write new state file: %1").arg(m_filePath);
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot create local state file: %1. Error: %2.")
.arg(m_filePath, m_error);
return false;
}
return true;
}
}
if (!file.open(QIODevice::ReadOnly))
{
m_error = QString("Cannot open state file: %1").arg(m_filePath);
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot open local state file: %1. Error: %2.")
.arg(m_filePath, file.errorString());
return false;
}
QByteArray raw = file.readAll();
file.close();
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
QByteArray raw = file.readAll();
file.close();
QJsonParseError parseError;
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
{
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString());
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state file is not valid JSON. File: %1. JSON error: %2.")
.arg(m_filePath, parseError.errorString());
return false;
}
m_state = doc.object();
m_loaded = true;
return true;
}
m_state = doc.object();
m_loaded = true;
return true;
}
bool LocalStateHelper::saveState() const
{
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;
}
QJsonDocument doc(m_state);
file.write(doc.toJson(QJsonDocument::Indented));
file.close();
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
{
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot save local state file: %1. Error: %2.")
.arg(m_filePath, writeError);
return false;
}
m_error.clear();
return true;
}
bool LocalStateHelper::isLoaded() const
{
return m_loaded;
}
QString LocalStateHelper::errorString() const
{
return m_error;
}
qint64 LocalStateHelper::maxPolicySeq() const
{
return m_state.value("max_policy_seq").toVariant().toLongLong();
}
QDateTime LocalStateHelper::lastSuccessRunAt() const
{
return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate);
}
QDateTime LocalStateHelper::lastOnlineVerifiedAt() const
{
return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate);
}
QString LocalStateHelper::lastSuccessVersion() const
{
return m_state.value("last_success_version").toString();
}
bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const
{
if (!m_loaded)
return false;
return policySeq < maxPolicySeq();
}
bool LocalStateHelper::isSystemTimeRewound() const
{
if (!m_loaded)
return false;
const QDateTime now = QDateTime::currentDateTimeUtc();
const QDateTime lastRun = lastSuccessRunAt();
const QDateTime lastOnline = lastOnlineVerifiedAt();
return (lastRun.isValid() && now < lastRun)
|| (lastOnline.isValid() && now < lastOnline);
}
void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq)
{
if (!m_loaded)
m_loaded = true;
m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["last_success_version"] = currentVersion;
if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq;
}
void LocalStateHelper::updateOnlineVerified(qint64 policySeq)
{
if (!m_loaded)
m_loaded = true;
m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq;
}
bool LocalStateHelper::isLoaded() const
{
return m_loaded;
}
QString LocalStateHelper::errorString() const
{
return m_error;
}
qint64 LocalStateHelper::maxPolicySeq() const
{
return m_state.value("max_policy_seq").toVariant().toLongLong();
}
QDateTime LocalStateHelper::lastSuccessRunAt() const
{
return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate);
}
QDateTime LocalStateHelper::lastOnlineVerifiedAt() const
{
return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate);
}
QString LocalStateHelper::lastSuccessVersion() const
{
return m_state.value("last_success_version").toString();
}
bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const
{
if (!m_loaded)
return false;
return policySeq < maxPolicySeq();
}
bool LocalStateHelper::isSystemTimeRewound() const
{
if (!m_loaded)
return false;
const QDateTime now = QDateTime::currentDateTimeUtc();
const QDateTime lastRun = lastSuccessRunAt();
const QDateTime lastOnline = lastOnlineVerifiedAt();
return (lastRun.isValid() && now < lastRun)
|| (lastOnline.isValid() && now < lastOnline);
}
void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq)
{
if (!m_loaded)
m_loaded = true;
m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["last_success_version"] = currentVersion;
if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq;
}
void LocalStateHelper::updateOnlineVerified(qint64 policySeq)
{
if (!m_loaded)
m_loaded = true;
m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq;
}
+33 -33
View File
@@ -1,33 +1,33 @@
#pragma once
#include <QString>
#include <QJsonObject>
#include <QDateTime>
class LocalStateHelper
{
public:
explicit LocalStateHelper(const QString& baseDir);
bool loadState(const QString& relativePath = "config/local_state.json");
bool saveState() const;
bool isLoaded() const;
QString errorString() const;
qint64 maxPolicySeq() const;
QDateTime lastSuccessRunAt() const;
QDateTime lastOnlineVerifiedAt() const;
QString lastSuccessVersion() const;
bool isPolicySeqRolledBack(qint64 policySeq) const;
bool isSystemTimeRewound() const;
void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq);
void updateOnlineVerified(qint64 policySeq);
private:
QString m_baseDir;
QString m_filePath;
QString m_error;
QJsonObject m_state;
bool m_loaded = false;
};
#pragma once
#include <QString>
#include <QJsonObject>
#include <QDateTime>
class LocalStateHelper
{
public:
explicit LocalStateHelper(const QString& baseDir);
bool loadState(const QString& relativePath = "config/local_state.json");
bool saveState() const;
bool isLoaded() const;
QString errorString() const;
qint64 maxPolicySeq() const;
QDateTime lastSuccessRunAt() const;
QDateTime lastOnlineVerifiedAt() const;
QString lastSuccessVersion() const;
bool isPolicySeqRolledBack(qint64 policySeq) const;
bool isSystemTimeRewound() const;
void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq);
void updateOnlineVerified(qint64 policySeq);
private:
QString m_baseDir;
QString m_filePath;
mutable QString m_error;
QJsonObject m_state;
bool m_loaded = false;
};
-24
View File
@@ -1,24 +0,0 @@
#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;
}
}
+158 -125
View File
@@ -1,156 +1,189 @@
#include "PolicyHelper.h"
#include "ConfigHelper.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <algorithm>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
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)
{
QFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
QFile file(path);
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;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
m_error = "Invalid policy JSON: " + error.errorString(); return false;
m_error = QCoreApplication::translate(
"PolicyHelper",
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
.arg(path, error.errorString());
return false;
}
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);
return loadPolicyObject(doc.object());
}
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
{
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();
}
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
{
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
return isValid();
}
bool PolicyHelper::savePolicy(const QString& relativePath) const
{
QSaveFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::WriteOnly)) return false;
QJsonObject stored = m_policy;
if (!m_signedText.isEmpty())
{
stored = QJsonObject{{"policy", m_policy}, {"policy_text", m_signedText}};
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
QString writeError;
const QString path = resolvePolicyPath(m_baseDir, relativePath, true);
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot save signed version policy file: %1. Error: %2.")
.arg(path, writeError);
return false;
}
const QByteArray bytes = QJsonDocument(stored).toJson(QJsonDocument::Indented);
return file.write(bytes) == bytes.size() && file.commit();
m_error.clear();
return true;
}
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
{
QJsonObject copy = policy; copy.remove("signature");
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end());
QJsonObject sorted;
for (const QString& key : keys) {
QJsonValue value = obj.value(key);
if (value.isObject()) value = self(self, value.toObject());
else if (value.isArray()) {
QJsonArray array;
for (QJsonValue item : value.toArray()) {
if (item.isObject()) item = self(self, item.toObject());
array.append(item);
}
value = array;
}
sorted.insert(key, value);
}
return sorted;
};
return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact);
}
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
{
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
{
QJsonObject copy = policy; copy.remove("signature");
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end());
QJsonObject sorted;
for (const QString& key : keys) {
QJsonValue value = obj.value(key);
if (value.isObject()) value = self(self, value.toObject());
else if (value.isArray()) {
QJsonArray array;
for (QJsonValue item : value.toArray()) {
if (item.isObject()) item = self(self, item.toObject());
array.append(item);
}
value = array;
}
sorted.insert(key, value);
}
return sorted;
};
return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact);
}
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"PolicyHelper",
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
return false;
#else
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open embedded policy public key"; return false; }
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) { m_error = "Invalid policy public key"; return false; }
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) m_error = "Invalid RSA policy signature";
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
QFile keyFile(keyPath);
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();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy public key is invalid: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) {
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;
#endif
}
bool PolicyHelper::isValid() const
{
if (!m_loaded) return false;
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"};
for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; }
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString());
}
bool PolicyHelper::isApplicable(const QString& appId, const QString& channel,
const QString& currentVersion) const
{
if (!isValid()) return false;
if (m_policy.value("app_id").toString() != appId
|| m_policy.value("channel").toString() != channel
|| m_policy.value("current_version").toString() != currentVersion)
{
m_error = "Policy identity does not match this installation";
bool PolicyHelper::isValid() const
{
if (!m_loaded) {
m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
return false;
}
return true;
}
QString PolicyHelper::errorString() const { return m_error; }
bool PolicyHelper::isVersionAllowed(const QString& version) const {
if (!isValid()) return false;
for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false;
return true;
}
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); }
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"};
for (const QString& key : required) {
if (!m_policy.contains(key)) {
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();
return verifySignature(payload, m_policy.value("signature").toString());
}
QString PolicyHelper::errorString() const { return m_error; }
bool PolicyHelper::isVersionAllowed(const QString& version) const {
if (!isValid()) return false;
for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false;
return true;
}
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").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::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 {
if (!isValid()) return true;
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry;
}
qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; }
QString PolicyHelper::message() const { return m_policy.value("message").toString(); }
if (!isValid()) return true;
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry;
}
qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; }
QString PolicyHelper::message() const { return m_policy.value("message").toString(); }
+31 -32
View File
@@ -1,35 +1,34 @@
#pragma once
#include <QString>
#include <QJsonObject>
class PolicyHelper
{
public:
explicit PolicyHelper(const QString& baseDir);
bool loadPolicy(const QString& relativePath = "config/version_policy.dat");
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
bool isValid() const;
bool isApplicable(const QString& appId, const QString& channel,
const QString& currentVersion) const;
QString errorString() const;
bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const;
bool forceUpdate() const;
#pragma once
#include <QString>
#include <QJsonObject>
class PolicyHelper
{
public:
explicit PolicyHelper(const QString& baseDir);
bool loadPolicy(const QString& relativePath = "config/version_policy.dat");
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
bool isValid() const;
QString errorString() const;
bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const;
bool forceUpdate() const;
bool allowRollback() const;
bool isOfflineAllowed() const;
bool gitTagsEnabled() const;
bool isExpired() const;
qint64 policySeq() const;
QString message() const;
private:
QByteArray canonicalPolicyBytes(const QJsonObject& obj) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const;
QString m_baseDir;
QJsonObject m_policy;
QString m_signedText;
mutable QString m_error;
bool m_loaded = false;
};
qint64 policySeq() const;
QString message() const;
private:
QByteArray canonicalPolicyBytes(const QJsonObject& obj) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const;
QString m_baseDir;
QJsonObject m_policy;
QString m_signedText;
mutable QString m_error;
bool m_loaded = false;
};
-52
View File
@@ -1,52 +0,0 @@
#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;
}
}
+144 -107
View File
@@ -1,144 +1,181 @@
#include "TicketHelper.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include "TicketHelper.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMessageAuthenticationCode>
#include <QSaveFile>
#include <QStandardPaths>
#include <QStringList>
#include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
{
return QMessageAuthenticationCode::hash(
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;
}
return QMessageAuthenticationCode::hash(
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
}
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage)
{
// Launcher 启动业务主程序前生成一次性 ticket。
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
if (errorMessage) {
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;
}
const QDateTime now = QDateTime::currentDateTimeUtc();
QJsonObject payload{
{"app_id", appId}, {"device_id", deviceId}, {"version", version},
{"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)},
{"issued_at", now.toString(Qt::ISODate)},
{"expires_at", now.addSecs(60).toString(Qt::ISODate)},
{"signature_alg", "HMAC-SHA256"}
};
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
const QDateTime now = QDateTime::currentDateTimeUtc();
QJsonObject payload{
{"app_id", appId}, {"device_id", deviceId}, {"version", version},
{"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)},
{"issued_at", now.toString(Qt::ISODate)},
{"expires_at", now.addSecs(60).toString(Qt::ISODate)},
{"signature_alg", "HMAC-SHA256"}
};
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) {
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");
QSaveFile file(path);
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
if (errorMessage) *errorMessage = "cannot save ticket";
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot save launch ticket file: %1. Error: %2.")
.arg(path, file.errorString());
}
return false;
}
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
if (ticketPath) *ticketPath = path;
return true;
}
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
if (ticketPath) *ticketPath = path;
return true;
}
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage)
{
if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty()
|| expectedVersion.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete";
return false;
}
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
const QString consumingPath = ticketPath + ".consuming."
+ QString::number(QCoreApplication::applicationPid());
if (!QFile::rename(ticketPath, consumingPath)) {
if (errorMessage) *errorMessage = "ticket missing or already consumed";
if (errorMessage) {
*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;
}
QFile file(consumingPath);
if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(consumingPath);
if (errorMessage) *errorMessage = "cannot read claimed ticket";
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot read claimed launch ticket: %1. Error: %2.")
.arg(consumingPath, file.errorString());
}
return false;
}
const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // Consume once so neither successful nor failed claims can be replayed.
QJsonParseError parseError;
const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure.
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false;
}
const QJsonObject wrapper = doc.object();
const QJsonObject payload = wrapper.value("payload").toObject();
const QByteArray actual = wrapper.value("signature").toString().toLatin1();
const QByteArray expected = ticketMac(payload, secret);
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 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)
&& expires >= now && issued.secsTo(expires) <= 65;
const QString nonce = payload.value("nonce").toString();
if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk
|| nonce.isEmpty()) {
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is not valid JSON. JSON error: %1.")
.arg(parseError.errorString());
}
return false;
}
return claimTicketNonce(expectedAppId, nonce, errorMessage);
const QJsonObject wrapper = doc.object();
const QJsonObject payload = wrapper.value("payload").toObject();
const QByteArray actual = wrapper.value("signature").toString().toLatin1();
const QByteArray expected = ticketMac(payload, secret);
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 now = QDateTime::currentDateTimeUtc();
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
&& expires >= now && issued.secsTo(expires) <= 65;
if (actual.isEmpty() || actual != expected) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"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;
}
if (payload.value("app_id").toString() != expectedAppId) {
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;
}
+13 -13
View File
@@ -1,13 +1,13 @@
#pragma once
#include <QString>
class TicketHelper
{
public:
static bool createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage = nullptr);
static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage = nullptr);
};
#pragma once
#include <QString>
class TicketHelper
{
public:
static bool createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage = nullptr);
static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage = nullptr);
};
-53
View File
@@ -1,53 +0,0 @@
#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
@@ -1,18 +0,0 @@
#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;
};
@@ -0,0 +1,81 @@
客户端文档入口
==============
你第一次打开 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 的发布源目录。
@@ -0,0 +1,432 @@
# 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`
@@ -0,0 +1,175 @@
# 客户端编译环境和第三方依赖说明
`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。
+40 -33
View File
@@ -1,35 +1,42 @@
set(_launcher_sources
cmake_minimum_required(VERSION 3.20)
project(LauncherDemo LANGUAGES C CXX)
# C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Qt 自动处理ui/moc/rcc
set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)
# 所有源码文件
set(SRC_LIST
main.cpp
UpdateLogic.h
UpdateLogic.cpp
)
if(WIN32)
set(_launcher_manifest "${CMAKE_CURRENT_SOURCE_DIR}/launcher.manifest")
if(NOT EXISTS "${_launcher_manifest}")
message(FATAL_ERROR "Launcher UAC manifest is missing: ${_launcher_manifest}")
endif()
list(APPEND _launcher_sources "${_launcher_manifest}")
endif()
add_executable(Launcher ${_launcher_sources})
target_compile_features(Launcher PRIVATE cxx_std_17)
target_link_libraries(Launcher
PRIVATE
Common
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)
set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE)
if(MSVC)
target_link_options(Launcher PRIVATE /MANIFESTUAC:NO)
endif()
UpdateLogic.h
UpdateLogic.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
# 生成可执行程序
add_executable(Launcher ${SRC_LIST})
if(WIN32)
set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
# 关键:添加OpenSSL头文件目录
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()
+228 -102
View File
@@ -1,117 +1,243 @@
#include "UpdateLogic.h"
#include "ConfigHelper.h"
#include <QDebug>
#include <QJsonArray>
#include <QApplication>
#include "PolicyHelper.h"
#include "LocalStateHelper.h"
#include "UpdateLogic.h"
#include "ConfigHelper.h"
#include <QDebug>
#include <QJsonArray>
#include <QApplication>
#include <QDir>
#include <QSaveFile>
#include "PolicyHelper.h"
#include "LocalStateHelper.h"
UpdateLogic::UpdateLogic(QObject* parent)
: QObject(parent)
{
ConfigHelper& cfg = ConfigHelper::instance();
m_serverAddr = cfg.getValue("Server", "api_base_url");
m_appId = cfg.getValue("App", "app_id");
m_curVer = cfg.getValue("App", "current_version");
m_channel = cfg.getValue("App", "channel");
// Debug print config
qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId;
}
void UpdateLogic::checkUpdate()
{
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty())
{
qDebug() << "Config incomplete, abort update check";
m_needUpdate = false;
return;
}
QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject body;
body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
{
qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code;
m_checkResp = resp;
m_networkOk = (code == 200);
if (code == 200)
{
const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy())
{
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false;
m_needUpdate = false;
return;
}
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState())
{
qDebug() << "Cannot persist online policy state";
m_networkOk = false;
m_needUpdate = false;
return;
}
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString();
qDebug() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
});
}
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
{
QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = ver;
body["version_id"] = verId;
QJsonArray files;
body["files"] = files;
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== File Download Url ==========";
qDebug() << resp["files"].toArray();
});
}
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
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;
});
UpdateLogic::UpdateLogic(QObject* parent)
: QObject(parent)
{
ConfigHelper& cfg = ConfigHelper::instance();
m_serverAddr = cfg.getValue("Server", "api_base_url");
m_appId = cfg.getValue("App", "app_id");
m_curVer = cfg.getValue("App", "current_version");
m_channel = cfg.getValue("App", "channel");
// Debug print config
qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId;
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;
}
void UpdateLogic::checkUpdate()
bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
{
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty())
{
qDebug() << "Config incomplete, abort update check";
m_needUpdate = false;
return;
m_error.clear();
if (m_serverAddr.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic", "Server address is empty.");
return false;
}
QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject response;
int statusCode = 0;
QJsonObject body;
body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
{
qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code;
m_checkResp = resp;
m_networkOk = (code == 200);
if (code == 200)
{
const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy())
{
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false;
m_needUpdate = false;
return;
}
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState())
{
qDebug() << "Cannot persist online policy state";
m_networkOk = false;
m_needUpdate = false;
return;
}
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString();
qDebug() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
m_http.postRequest(m_serverAddr + "/api/v1/git/tags", body,
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
});
}
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
{
QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = ver;
body["version_id"] = verId;
QJsonArray files;
body["files"] = files;
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;
}
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== File Download Url ==========";
qDebug() << resp["files"].toArray();
});
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)
{
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = m_appId;
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
body["result"] = success ? "success" : "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== Update Report Result ==========";
qDebug() << resp;
});
}
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = m_appId;
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
body["result"] = success ? "success" : "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== Update Report Result ==========";
qDebug() << resp;
});
}
+18 -13
View File
@@ -10,15 +10,19 @@ class UpdateLogic : public QObject
public:
explicit UpdateLogic(QObject* parent = nullptr);
void checkUpdate();
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);
bool getNeedUpdate() const { return m_needUpdate; }
QString getLatestVersion() const { return m_latestVer; }
QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; }
int lastStatusCode() const { return m_lastStatusCode; }
void checkUpdate();
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);
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; }
QString getLatestVersion() const { return m_latestVer; }
QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; }
int lastStatusCode() const { return m_lastStatusCode; }
QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; }
QString getChannel() const { return m_channel; }
@@ -32,7 +36,8 @@ private:
bool m_needUpdate = false;
bool m_networkOk = false;
int m_lastStatusCode = 0;
QString m_latestVer;
QJsonObject m_checkResp;
};
int m_lastStatusCode = 0;
QString m_latestVer;
QJsonObject m_checkResp;
QString m_error;
};
-10
View File
@@ -1,10 +0,0 @@
<?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>
+300 -334
View File
@@ -1,460 +1,426 @@
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QMessageBox>
#include <QProcess>
#include <QProgressDialog>
#include <QInputDialog>
#include <QLineEdit>
#include <QTranslator>
#include "UpdateLogic.h"
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include "../Common/IntegrityHelper.h"
#include "../Common/ManifestBootstrapPolicy.h"
#include "../Common/TranslationHelper.h"
#include "../Updater/UpdaterLogic.h"
#include <QFile>
#include <QFileDialog>
#include <QDir>
int main(int argc, char *argv[])
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include <QFile>
#include <QFileDialog>
#include <QDir>
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
TranslationHelper translation;
translation.installForSystemLocale();
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher"));
progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
progress.setAutoClose(false);
progress.show();
QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath();
ConfigHelper &config = ConfigHelper::instance();
const auto isLicenseError = [&translation](const QString &errorText)
{
const QString text = errorText.toLower();
QStringList markers{QStringLiteral("license")};
const char *const markerSources[]{
QT_TRANSLATE_NOOP("LauncherErrorMarker", "authorization"),
QT_TRANSLATE_NOOP("LauncherErrorMarker", "expired"),
QT_TRANSLATE_NOOP("LauncherErrorMarker", "device limit reached"),
QT_TRANSLATE_NOOP("LauncherErrorMarker", "bound to another license")};
for (const char *source : markerSources)
{
markers.append(QCoreApplication::translate("LauncherErrorMarker", source));
markers.append(translation.translate("LauncherErrorMarker", source));
}
for (const QString &marker : markers)
{
if (!marker.isEmpty() && text.contains(marker.toLower()))
return true;
}
return false;
progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
progress.setAutoClose(false);
progress.show();
QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) {
const QString text = errorText.toLower();
return text.contains("license")
|| text.contains("authorization")
|| text.contains("expired")
|| text.contains("device limit")
|| text.contains("bound to another license");
};
const auto clearDeviceCredential = [&]()
{
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
const auto clearDeviceCredential = [&]() {
ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath());
config.setValue("Update", "device_id", QString());
};
QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString &reason) -> QString
{
QString promptReason = reason;
while (true)
{
progress.close();
QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
QString promptReason = reason;
while (true) {
progress.close();
bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed();
const QString displayName = appName.isEmpty()
? QCoreApplication::translate("Launcher", "application")
: appName;
const QString prompt = promptReason.trimmed().isEmpty()
? QCoreApplication::translate("Launcher", "Enter the license for %1:").arg(displayName)
: QCoreApplication::translate("Launcher", "%1\n\nPlease re-enter the license for %2:").arg(promptReason, displayName);
? QCoreApplication::translate("Launcher", "Please enter the License for %1:")
.arg(appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName)
: QCoreApplication::translate("Launcher", "%1\n\nPlease enter a new License for %2:")
.arg(promptReason, appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName);
licenseKey = QInputDialog::getText(
nullptr,
QCoreApplication::translate("Launcher", "Enter License"),
prompt,
QLineEdit::Normal,
QString(),
&accepted)
.trimmed();
if (!accepted)
{
QMessageBox::information(
nullptr,
nullptr,
QCoreApplication::translate("Launcher", "Enter License"),
prompt,
QLineEdit::Normal,
QString(),
&accepted
).trimmed();
if (!accepted) {
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Required"),
QCoreApplication::translate("Launcher", "A license provided by an administrator is required for the first launch."));
QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch."));
return QString();
}
if (licenseKey.isEmpty())
{
QMessageBox::warning(
nullptr,
if (licenseKey.isEmpty()) {
QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
QCoreApplication::translate("Launcher", "Paste the license created by an administrator."));
QCoreApplication::translate("Launcher", "Please paste the License created in the admin page."));
promptReason.clear();
continue;
}
if (!config.setValue("License", "license_key", licenseKey))
{
QMessageBox::critical(
nullptr,
if (!config.setValue("License", "license_key", licenseKey)) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Failed to Save License"),
QCoreApplication::translate("Launcher", "Cannot save system state at %1:\n%2")
.arg(config.configPath(), config.lastError()));
QCoreApplication::translate("Launcher", "Cannot write the configuration file:\n%1\n%2").arg(config.configPath(), config.lastError()));
return QString();
}
progress.show();
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying license..."));
QApplication::processEvents();
return licenseKey;
}
};
while (true)
{
if (licenseKey.isEmpty())
{
licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty())
return 0;
}
DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"),
config.getValue("App", "app_id"),
config.getValue("App", "channel"),
licenseKey))
{
break;
}
const QString error = identity.errorString();
if (!isLicenseError(error))
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License..."));
QApplication::processEvents();
return licenseKey;
}
};
while (true) {
// License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。
// 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。
if (licenseKey.isEmpty()) {
licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0;
}
DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"),
config.getValue("App", "app_id"),
config.getValue("App", "channel"),
licenseKey)) {
break;
}
const QString error = identity.errorString();
if (!isLicenseError(error)) {
progress.close();
QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Device Authentication Failed"), error);
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Device Authentication Failed"),
error);
return -1;
}
clearDeviceCredential();
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current license cannot be used: %1").arg(error));
if (licenseKey.isEmpty())
return 0;
}
UpdateLogic logic;
logic.checkUpdate();
const bool needUpdate = logic.getNeedUpdate();
const bool networkOk = logic.isNetworkOk();
const QString latestVer = logic.getLatestVersion();
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error));
if (licenseKey.isEmpty()) return 0;
}
UpdateLogic logic;
logic.checkUpdate();
const bool needUpdate = logic.getNeedUpdate();
const bool networkOk = logic.isNetworkOk();
const QString latestVer = logic.getLatestVersion();
const QJsonObject response = logic.getCheckResult();
const int targetVersionId = response.value("version_id").toInt();
const QString appId = logic.getAppId();
const QString channel = logic.getChannel();
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403)
{
progress.close();
const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close();
const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty()
? QCoreApplication::translate("Launcher", "The device or license authorization is invalid.")
: message;
if (isLicenseError(displayMessage))
{
? QCoreApplication::translate("Launcher", "The device or License authorization is invalid.")
: message;
if (isLicenseError(displayMessage)) {
clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(
QCoreApplication::translate("Launcher", "The server rejected the current license: %1").arg(displayMessage));
if (!newLicense.isEmpty())
{
const QString newLicense = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current authorization was rejected by the server: %1").arg(displayMessage));
if (!newLicense.isEmpty()) {
progress.close();
QMessageBox::information(
nullptr,
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Saved"),
QCoreApplication::translate("Launcher", "Restart Launcher to complete device authorization and check for updates."));
QCoreApplication::translate("Launcher", "Please restart Launcher to complete device authorization and update checking."));
}
return 0;
}
QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Authorization Denied"), displayMessage);
return -1;
}
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
const auto configuredName = [&](const QString &key, const QString &fallback)
{
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Authorization Rejected"),
displayMessage);
return -1;
}
const auto configuredName = [&](const QString& key, const QString& fallback) {
return ConfigHelper::executableNameForCurrentPlatform(
config.getValue("Runtime", key), fallback);
};
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
IntegrityHelper integrity(config.installRoot());
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,
const auto importOfflinePackage = [&]() {
const QString package = QFileDialog::getOpenFileName(nullptr,
QCoreApplication::translate("Launcher", "Select Offline Update Package"),
QString(),
QCoreApplication::translate("Launcher", "Marsco Offline Update Package (*.upd)"));
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)"));
if (package.isEmpty())
return false;
if (!QFileInfo::exists(updaterPath)) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot import the offline update package because the updater executable does not exist.\nUpdater: %1\nOffline package: %2")
.arg(updaterPath, package));
return false;
}
if (!QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)})) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater for offline package import.\nUpdater: %1\nOffline package: %2\nCheck file permissions and dependent DLLs/shared libraries.")
.arg(updaterPath, package));
return false;
}
return true;
};
const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline"))
{
if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close();
if (!importOfflinePackage())
QMessageBox::information(
nullptr,
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected."));
return 0;
}
const auto startMainApp = [&]()
{
QString mainStartupError;
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 ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError))
{
launchToken, &ticketPath, &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;
}
const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started)
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) {
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;
};
progress.setLabelText(QCoreApplication::translate("Launcher", "Validating the local run policy..."));
QApplication::processEvents();
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
progress.close();
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Unable to Start"),
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy..."));
QApplication::processEvents();
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot 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()));
}
if (policy.isExpired())
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "The local version policy has expired. Please connect to the network or contact the administrator."));
return -1;
}
if (policy.isExpired())
{
progress.close();
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Unable to Start"),
QCoreApplication::translate("Launcher", "The local version policy has expired. Connect to the network or contact an administrator."));
return -1;
}
const bool currentVersionAllowed =
policy.allowRun() && policy.isVersionAllowed(currentVersion);
if (!currentVersionAllowed && !(networkOk && needUpdate))
{
progress.close();
QMessageBox::critical(
nullptr,
}
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Current Version Cannot Run"),
policy.message().isEmpty()
? QCoreApplication::translate("Launcher", "Version %1 has been disabled by an administrator.").arg(currentVersion)
: policy.message());
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
progress.close();
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Launcher", "Unable to Start"),
policy.message().isEmpty() ? QCoreApplication::translate("Launcher", "The current version %1 has been disabled by the administrator.").arg(currentVersion)
: policy.message());
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
progress.close();
QMessageBox::critical(
nullptr,
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected, so startup was blocked."));
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected. Startup has been blocked."));
return -1;
}
}
if (state.isSystemTimeRewound())
{
progress.close();
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A possible system clock rollback was detected, so startup was blocked."));
QCoreApplication::translate("Launcher", "A possible system time rollback was detected. Startup has been blocked."));
return -1;
}
if (networkOk && policy.gitTagsEnabled())
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Generating Git tag list..."));
QApplication::processEvents();
const QString tagsPath = QDir(appDir).filePath("tags.txt");
if (!logic.refreshGitTagsFile(tagsPath))
{
progress.close();
QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "Git Tag List Failed"),
QCoreApplication::translate("Launcher", "Cannot generate tags.txt, but startup will continue:\n%1").arg(logic.errorString()));
progress.show();
QApplication::processEvents();
}
}
if (networkOk && needUpdate)
{
progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
const bool updateRequired = policy.forceUpdate() || !currentVersionAllowed;
const QString dialogTitle = rollbackOperation
? QCoreApplication::translate("Launcher", "Version Rollback")
: (updateRequired
? QCoreApplication::translate("Launcher", "Update Required")
: QCoreApplication::translate("Launcher", "New Version Available"));
const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback")
: (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available"));
const QString prompt = policy.message().isEmpty()
? (rollbackOperation
? QCoreApplication::translate("Launcher", "An administrator selected version %1 as the rollback target. Downgrade now?")
: QCoreApplication::translate("Launcher", "Version %1 is available. Update now?"))
.arg(latestVer)
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
bool accepted = true;
if (updateRequired)
{
QMessageBox::information(nullptr, dialogTitle, prompt);
}
else
{
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
}
if (!accepted)
{
if (!startMainApp())
{
QMessageBox::critical(
nullptr,
? (rollbackOperation
? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer)
: QCoreApplication::translate("Launcher", "Version %1 is available. Update now?").arg(latestVer))
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
bool accepted = true;
if (policy.forceUpdate()) {
QMessageBox::information(nullptr, dialogTitle, prompt);
} else {
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
}
if (!accepted) {
if (!startMainApp()) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1;
}
return 0;
}
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))
{
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath));
QCoreApplication::translate(
"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 0;
}
if (!networkOk)
if (networkOk && !needUpdate && targetVersionId > 0 && latestVer == currentVersion)
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Caching signed version manifest..."));
QApplication::processEvents();
const QString manifestCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("manifest_cache");
if (!logic.cacheManifest(appId, channel, currentVersion, targetVersionId, manifestCacheDir))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Manifest Cache Failed"),
QCoreApplication::translate("Launcher", "Cannot cache the signed manifest for the current version: %1").arg(logic.errorString()));
return -1;
}
}
if (!networkOk) {
progress.close();
if (QMessageBox::question(nullptr,
QCoreApplication::translate("Launcher", "Server Unavailable"),
QCoreApplication::translate("Launcher", "The update server is currently unreachable. Import an offline update package?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes)
{
QCoreApplication::translate("Launcher", "Cannot connect to the update server. Import an offline update package?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
if (!importOfflinePackage())
QMessageBox::information(
nullptr,
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started."));
return 0;
}
progress.show();
}
}
progress.show();
}
if (!networkOk && !policy.isOfflineAllowed())
{
progress.close();
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Network Unavailable"),
QCoreApplication::translate("Launcher", "The update server is unreachable and the current policy does not allow offline startup."));
QCoreApplication::translate("Launcher", "Cannot connect to the update server, and the current policy does not allow offline startup."));
return -1;
}
progress.setLabelText(
networkOk ? QCoreApplication::translate("Launcher", "The current version is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
QApplication::processEvents();
progress.setLabelText(networkOk
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
QApplication::processEvents();
if (!startMainApp())
{
progress.close();
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1;
}
progress.close();
return 0;
}
progress.close();
return 0;
}
+19 -14
View File
@@ -1,18 +1,23 @@
set(_main_app_sources
project(MainApp)
add_executable(MainApp
main.cpp
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)
add_executable(MainApp ${_main_app_sources})
target_compile_features(MainApp PRIVATE cxx_std_17)
target_link_libraries(MainApp
PRIVATE
Common
Qt5::Core
Qt5::Gui
Qt5::Widgets
)
update_client_link_embedded_resources(MainApp)
update_client_deploy_openssl_runtime(MainApp)
update_client_enable_qt_deployment(MainApp)
if(WIN32)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
add_custom_command(TARGET MainApp POST_BUILD
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
)
endif()
+11 -11
View File
@@ -12,10 +12,10 @@ static QString readDllVersion(const QString& dllPath)
{
QFile file(dllPath);
if (!file.exists())
return QCoreApplication::translate("MainWindow", "missing");
return "missing";
if (!file.open(QIODevice::ReadOnly))
return QCoreApplication::translate("MainWindow", "unreadable");
return "unreadable";
QByteArray data = file.read(1024);
file.close();
@@ -25,12 +25,12 @@ static QString readDllVersion(const QString& dllPath)
MainWindow::MainWindow(QWidget* parent)
: QWidget(parent)
{
this->setWindowTitle(tr("Marsco Demo MainApp"));
this->setWindowTitle("Marsco Demo MainApp");
this->resize(600, 400);
QString appVersion = ConfigHelper::instance().getValue("App", "current_version");
if (appVersion.isEmpty())
appVersion = tr("unknown");
appVersion = "unknown";
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
@@ -38,27 +38,27 @@ MainWindow::MainWindow(QWidget* parent)
QString policyResult;
if (!policy.loadPolicy("config/version_policy.dat"))
{
policyResult = tr("policy missing");
policyResult = "policy missing";
}
else if (!policy.isValid())
{
policyResult = tr("policy invalid");
policyResult = "policy invalid";
}
else if (!policy.isVersionAllowed(appVersion))
{
policyResult = tr("version disabled");
policyResult = "version disabled";
}
else if (policy.isExpired())
{
policyResult = tr("policy expired");
policyResult = "policy expired";
}
else
{
policyResult = tr("policy ok");
policyResult = "policy ok";
}
QVBoxLayout* layout = new QVBoxLayout(this);
QLabel* label = new QLabel(tr("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
.arg(appVersion)
.arg(dllVersion)
.arg(policyResult));
@@ -69,4 +69,4 @@ MainWindow::MainWindow(QWidget* parent)
layout->addWidget(label);
this->setLayout(layout);
}
}
+129 -156
View File
@@ -1,162 +1,135 @@
#include <QApplication>
#include <QDebug>
#include <QMessageBox>
#include <QDir>
#include <QFileInfo>
#include <QSaveFile>
#include "MainWindow.h"
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include "../Common/TranslationHelper.h"
int main(int argc, char* argv[])
{
QApplication a(argc, argv);
QApplication::setApplicationName("Marsco MainApp");
TranslationHelper translation;
translation.installForSystemLocale();
qDebug() << Qt::endl << "entered main app" << Qt::endl;
#include <QApplication>
#include <QDebug>
#include <QMessageBox>
#include <QDir>
#include <QFileInfo>
#include <QSaveFile>
#include <QTranslator>
#include "MainWindow.h"
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h"
int main(int argc, char* argv[])
{
QApplication a(argc, argv);
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
a.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
qDebug() << Qt::endl << "entered main app" << Qt::endl;
ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
QString ticketFilePath;
QString healthFilePath;
for (int i = 1; i < argc; ++i)
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher");
QString ticketFilePath;
QString healthFilePath;
for (int i = 1; i < argc; ++i)
{
const QString arg(argv[i]);
if (arg.startsWith("--ticket-file="))
ticketFilePath = arg.mid(QString("--ticket-file=").size());
else if (arg.startsWith("--health-file="))
healthFilePath = arg.mid(QString("--health-file=").size());
}
QString ticketError;
if (ticketFilePath.isEmpty()
|| !TicketHelper::consumeAndVerify(ticketFilePath,
config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
&ticketError))
{
QMessageBox::critical(nullptr, "Startup Restriction",
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.")
.arg(ticketError, launcherExecutable));
return -1;
}
QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
{
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
return -1;
}
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
return -1;
}
if (policy.isExpired())
{
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
return -1;
}
if (state.isSystemTimeRewound())
{
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
return -1;
}
IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version")))
{
const QString arg(argv[i]);
if (arg.startsWith("--ticket-file="))
ticketFilePath = arg.mid(QString("--ticket-file=").size());
else if (arg.startsWith("--health-file="))
healthFilePath = arg.mid(QString("--health-file=").size());
}
QString ticketError;
if (ticketFilePath.isEmpty()
|| !TicketHelper::consumeAndVerify(ticketFilePath,
config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
&ticketError))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Startup Restriction"),
QCoreApplication::translate("MainApp", "Invalid or missing one-time launch ticket: %1\nPlease use %2.")
.arg(ticketError, launcherExecutable));
return -1;
}
QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "License Error"),
QCoreApplication::translate("MainApp", "Local license invalid: %1").arg(identity.errorString()));
return -1;
}
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Policy Error"),
QCoreApplication::translate("MainApp", "Local policy invalid: %1").arg(policy.errorString()));
return -1;
}
if (policy.isExpired())
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Policy Error"),
QCoreApplication::translate("MainApp", "Policy expired. The application cannot start."));
return -1;
}
const QString currentVersion = config.getValue("App", "current_version");
if (!policy.isApplicable(config.getValue("App", "app_id"),
config.getValue("App", "channel"),
currentVersion)
|| !policy.allowRun() || !policy.isVersionAllowed(currentVersion))
{
QMessageBox::critical(
nullptr,
QCoreApplication::translate("MainApp", "Policy Error"),
QCoreApplication::translate(
"MainApp",
"The signed policy does not allow this installed version to run."));
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "State Error"),
QCoreApplication::translate("MainApp", "Cannot load local state: %1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Policy Error"),
QCoreApplication::translate("MainApp", "A policy sequence rollback was detected, so startup was blocked."));
return -1;
}
if (state.isSystemTimeRewound())
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Policy Error"),
QCoreApplication::translate("MainApp", "The system clock appears to have moved backward, so startup was blocked."));
return -1;
}
IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"),
currentVersion))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Integrity Check Failed"),
QCoreApplication::translate("MainApp", "Application files failed signed manifest verification:\n%1")
QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Startup blocked because the installed files failed local signed Manifest verification.\n"
"This is not a download check; it means the current installation directory does not match the signed Manifest cache for this version.\n\n"
"Details:\n%1")
.arg(integrity.errorString()));
return -1;
}
MainWindow w;
w.show();
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState())
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "State Error"),
QCoreApplication::translate("MainApp", "Cannot save local state: %1").arg(state.errorString()));
return -1;
}
if (!healthFilePath.isEmpty())
{
QDir().mkpath(QFileInfo(healthFilePath).path());
QSaveFile healthFile(healthFilePath);
const QByteArray healthy("ok\n");
if (!healthFile.open(QIODevice::WriteOnly)
|| healthFile.write(healthy) != healthy.size()
|| !healthFile.commit())
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("MainApp", "Startup Error"),
QCoreApplication::translate("MainApp", "Cannot write the update health confirmation file."));
return -1;
}
}
qDebug() << "Main program MainApp is running normally";
return a.exec();
}
MainWindow w;
w.show();
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState())
{
QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString()));
return -1;
}
if (!healthFilePath.isEmpty())
{
QDir().mkpath(QFileInfo(healthFilePath).path());
QSaveFile healthFile(healthFilePath);
const QByteArray healthy("ok\n");
if (!healthFile.open(QIODevice::WriteOnly)
|| healthFile.write(healthy) != healthy.size()
|| !healthFile.commit())
{
QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file.");
return -1;
}
}
qDebug() << "Main program MainApp is running normally";
return a.exec();
}
-78
View File
@@ -1,78 +0,0 @@
# 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/ 保持的架构和操作文档
```
+41 -23
View File
@@ -1,24 +1,42 @@
set(_updater_sources
main.cpp
UpdateTransaction.h
UpdateTransaction.cpp
)
add_executable(Updater ${_updater_sources})
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)
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
if(WIN32 AND MSVC)
# 强制所有编译、设计时生成均使用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
UpdaterLogic.h
UpdaterLogic.cpp
UpdateTransaction.h
UpdateTransaction.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
add_executable(Updater ${SRC})
if(WIN32)
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
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()
+268 -261
View File
@@ -1,274 +1,281 @@
#include "UpdateTransaction.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <QSet>
#include <QUuid>
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId)
: m_installDir(QDir::cleanPath(installDir)),
m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)),
m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")),
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
{
m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId);
m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId);
}
bool UpdateTransaction::safeRelativePath(const QString& path)
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
}
bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const
{
if (!QDir().mkpath(QFileInfo(destination).path())) return false;
if (QFile::exists(destination) && !QFile::remove(destination)) return false;
return QFile::copy(source, destination);
}
#include "UpdateTransaction.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <QSet>
#include <QUuid>
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId)
: m_installDir(QDir::cleanPath(installDir)),
m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)),
m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")),
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
{
m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId);
m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId);
}
bool UpdateTransaction::safeRelativePath(const QString& path)
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
}
bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const
{
if (!QDir().mkpath(QFileInfo(destination).path())) return false;
if (QFile::exists(destination) && !QFile::remove(destination)) return false;
return QFile::copy(source, destination);
}
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["from_version"] = m_fromVersion;
m_state["to_version"] = m_toVersion;
m_state["status"] = status;
m_state["manifest_id"] = m_manifestId;
m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["staging_dir"] = m_stagingDir;
m_state["backup_dir"] = m_backupDir;
m_state["error_code"] = errorCode;
m_state["message"] = message;
QJsonArray paths;
for (const QString& path : m_changedPaths) paths.append(path);
m_state["changed_paths"] = paths;
QJsonArray obsoletePaths;
for (const QString& path : m_obsoletePaths) obsoletePaths.append(path);
m_state["obsolete_paths"] = obsoletePaths;
QDir().mkpath(m_updateDir);
QSaveFile file(m_stateFile);
if (!file.open(QIODevice::WriteOnly)) return false;
const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented);
if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; }
return file.commit();
}
bool UpdateTransaction::initialize()
{
QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively();
if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false;
m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
return writeState("prepared");
}
bool UpdateTransaction::resumeExisting(QString* errorMessage)
{
QFile file(m_stateFile);
if (!file.open(QIODevice::ReadOnly)) {
if (errorMessage) *errorMessage = "cannot open upgrade_state.json";
return false;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
if (!doc.isObject()) {
if (errorMessage) *errorMessage = "invalid upgrade_state.json";
return false;
}
m_state = doc.object();
const QString expectedToVersion = m_toVersion;
const int expectedManifestId = m_manifestId;
const QString stateStatus = m_state.value("status").toString();
m_transactionId = m_state.value("transaction_id").toString();
m_fromVersion = m_state.value("from_version").toString();
m_toVersion = m_state.value("to_version").toString();
m_manifestId = m_state.value("manifest_id").toInt();
if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId
|| (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify"
&& stateStatus != "rollback_required")) {
if (errorMessage) *errorMessage = "upgrade state does not match resume request";
return false;
}
m_stagingDir = m_state.value("staging_dir").toString();
m_backupDir = m_state.value("backup_dir").toString();
m_changedPaths.clear();
m_obsoletePaths.clear();
for (const QJsonValue& value : m_state.value("changed_paths").toArray()) {
const QString path = value.toString();
if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe path in upgrade state";
return false;
}
m_changedPaths.append(path);
}
for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) {
const QString path = value.toString();
if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state";
return false;
}
m_obsoletePaths.append(path);
}
if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) {
if (errorMessage) *errorMessage = "incomplete upgrade state";
return false;
}
return true;
}
bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
const QStringList& obsoletePaths)
{
m_changedPaths.clear();
m_obsoletePaths.clear();
QSet<QString> changedKeys;
for (const QString& path : changedPaths) {
if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path);
changedKeys.insert(normalized.toCaseFolded());
m_changedPaths.append(normalized);
}
for (const QString& path : obsoletePaths) {
if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path);
if (changedKeys.contains(normalized.toCaseFolded())) return false;
m_obsoletePaths.append(normalized);
}
return writeState("verified");
}
m_state["from_version"] = m_fromVersion;
m_state["to_version"] = m_toVersion;
m_state["status"] = status;
m_state["manifest_id"] = m_manifestId;
m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["staging_dir"] = m_stagingDir;
m_state["backup_dir"] = m_backupDir;
m_state["error_code"] = errorCode;
m_state["message"] = message;
QJsonArray paths;
for (const QString& path : m_changedPaths) paths.append(path);
m_state["changed_paths"] = paths;
QJsonArray obsoletePaths;
for (const QString& path : m_obsoletePaths) obsoletePaths.append(path);
m_state["obsolete_paths"] = obsoletePaths;
QDir().mkpath(m_updateDir);
QSaveFile file(m_stateFile);
if (!file.open(QIODevice::WriteOnly)) return false;
const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented);
if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; }
return file.commit();
}
bool UpdateTransaction::initialize()
{
QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively();
if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false;
m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
return writeState("prepared");
}
bool UpdateTransaction::resumeExisting(QString* errorMessage)
{
QFile file(m_stateFile);
if (!file.open(QIODevice::ReadOnly)) {
if (errorMessage) *errorMessage = "cannot open upgrade_state.json";
return false;
}
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
if (!doc.isObject()) {
if (errorMessage) *errorMessage = "invalid upgrade_state.json";
return false;
}
m_state = doc.object();
const QString expectedToVersion = m_toVersion;
const int expectedManifestId = m_manifestId;
const QString stateStatus = m_state.value("status").toString();
m_transactionId = m_state.value("transaction_id").toString();
m_fromVersion = m_state.value("from_version").toString();
m_toVersion = m_state.value("to_version").toString();
m_manifestId = m_state.value("manifest_id").toInt();
if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId
|| (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify"
&& stateStatus != "rollback_required")) {
if (errorMessage) *errorMessage = "upgrade state does not match resume request";
return false;
}
m_stagingDir = m_state.value("staging_dir").toString();
m_backupDir = m_state.value("backup_dir").toString();
m_changedPaths.clear();
m_obsoletePaths.clear();
for (const QJsonValue& value : m_state.value("changed_paths").toArray()) {
const QString path = value.toString();
if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe path in upgrade state";
return false;
}
m_changedPaths.append(path);
}
for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) {
const QString path = value.toString();
if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state";
return false;
}
m_obsoletePaths.append(path);
}
if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) {
if (errorMessage) *errorMessage = "incomplete upgrade state";
return false;
}
return true;
}
bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
const QStringList& obsoletePaths)
{
m_changedPaths.clear();
m_obsoletePaths.clear();
QSet<QString> changedKeys;
for (const QString& path : changedPaths) {
if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path);
changedKeys.insert(normalized.toCaseFolded());
m_changedPaths.append(normalized);
}
for (const QString& path : obsoletePaths) {
if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path);
if (changedKeys.contains(normalized.toCaseFolded())) return false;
m_obsoletePaths.append(normalized);
}
return writeState("verified");
}
bool UpdateTransaction::backupCurrentFiles()
{
// 替换前先备份所有将被修改或删除的文件。
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
if (!writeState("waiting_mainapp_exit")) return false;
QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths);
for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path);
if (!QFile::exists(installed)) continue;
const QString backup = QDir(m_backupDir).filePath(path);
if (!copyOverwrite(installed, backup))
return markFailed("backup_failed", path);
}
return writeState("backed_up");
}
QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths);
for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path);
if (!QFile::exists(installed)) continue;
const QString backup = QDir(m_backupDir).filePath(path);
if (!copyOverwrite(installed, backup))
return markFailed("backup_failed", path);
}
return writeState("backed_up");
}
bool UpdateTransaction::installStagedFiles(QString* failedPath)
{
// staging 目录里只放已经下载并校验过 hash 的新文件。
// 真正覆盖安装目录时如果任意一个文件失败,就进入 rollback_required。
if (!writeState("replacing")) return false;
for (const QString& path : m_changedPaths) {
const QString source = QDir(m_stagingDir).filePath(path);
const QString destination = QDir(m_installDir).filePath(path);
if (!copyOverwrite(source, destination)) {
if (failedPath) *failedPath = path;
writeState("rollback_required", "replace_failed", path);
return false;
}
}
return writeState("replaced");
}
bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); }
bool UpdateTransaction::markRollbackRequired(const QString& reason)
{ return writeState("rollback_required", "post_install_failed", reason); }
bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); }
bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); }
bool UpdateTransaction::rollback(QString* failedPath)
{
writeState("rolling_back");
bool ok = true;
QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths);
for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path);
const QString backup = QDir(m_backupDir).filePath(path);
if (QFile::exists(backup)) {
if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; }
} else if (QFile::exists(installed) && !QFile::remove(installed)) {
ok = false; if (failedPath) *failedPath = path;
}
}
writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed",
failedPath ? *failedPath : QString());
return ok;
}
bool UpdateTransaction::commit()
{
if (!writeState("committed")) return false;
QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively();
return true;
}
bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message)
{ return writeState("failed", errorCode, message); }
QString UpdateTransaction::transactionId() const { return m_transactionId; }
QString UpdateTransaction::fromVersion() const { return m_fromVersion; }
QString UpdateTransaction::stagingDir() const { return m_stagingDir; }
QString UpdateTransaction::backupDir() const { return m_backupDir; }
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
for (const QString& path : m_changedPaths) {
const QString source = QDir(m_stagingDir).filePath(path);
const QString destination = QDir(m_installDir).filePath(path);
if (!copyOverwrite(source, destination)) {
if (failedPath) *failedPath = path;
writeState("rollback_required", "replace_failed", path);
return false;
}
}
return writeState("replaced");
}
bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); }
bool UpdateTransaction::markRollbackRequired(const QString& reason)
{ return writeState("rollback_required", "post_install_failed", reason); }
bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); }
bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); }
bool UpdateTransaction::rollback(QString* failedPath)
{
writeState("rolling_back");
bool ok = true;
QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths);
for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path);
const QString backup = QDir(m_backupDir).filePath(path);
if (QFile::exists(backup)) {
if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; }
} else if (QFile::exists(installed) && !QFile::remove(installed)) {
ok = false; if (failedPath) *failedPath = path;
}
}
writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed",
failedPath ? *failedPath : QString());
return ok;
}
bool UpdateTransaction::commit()
{
if (!writeState("committed")) return false;
QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively();
return true;
}
bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message)
{ return writeState("failed", errorCode, message); }
QString UpdateTransaction::transactionId() const { return m_transactionId; }
QString UpdateTransaction::fromVersion() const { return m_fromVersion; }
QString UpdateTransaction::stagingDir() const { return m_stagingDir; }
QString UpdateTransaction::backupDir() const { return m_backupDir; }
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion, QString* errorMessage)
{
// 启动时恢复未完成事务:如果上次升级停在替换/验证/回滚中间,优先恢复到可启动状态。
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
.filePath("upgrade_state.json");
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile))
stateFile = legacyStateFile;
QFile file(stateFile);
if (!file.exists()) return true;
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; }
QJsonObject state = doc.object();
const QString status = state.value("status").toString();
if (status == "committed") return true;
if (status == "rolled_back") {
if (restoredVersion) *restoredVersion = state.value("from_version").toString();
return true;
}
const QString backupDir = state.value("backup_dir").toString();
QJsonArray paths = state.value("changed_paths").toArray();
for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value);
const QString errorCode = state.value("error_code").toString();
const bool mayContainNewFiles = status == "replacing" || status == "replaced"
|| status == "post_verify" || status == "rollback_required"
|| status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed";
bool ok = true;
for (const QJsonValue& value : paths) {
const QString path = value.toString();
if (!safeRelativePath(path)) { ok = false; continue; }
const QString installed = QDir(installDir).filePath(path);
const QString backup = QDir(backupDir).filePath(path);
if (QFile::exists(backup)) {
QDir().mkpath(QFileInfo(installed).path());
if (QFile::exists(installed)) QFile::remove(installed);
if (!QFile::copy(backup, installed)) ok = false;
} else if (mayContainNewFiles) {
if (QFile::exists(installed) && !QFile::remove(installed)) ok = false;
}
}
if (restoredVersion) *restoredVersion = state.value("from_version").toString();
state["status"] = ok ? "rolled_back" : "failed";
QSaveFile output(stateFile);
if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); }
if (!ok && errorMessage) *errorMessage = "failed to restore one or more files";
return ok;
}
.filePath("upgrade_state.json");
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile))
stateFile = legacyStateFile;
QFile file(stateFile);
if (!file.exists()) return true;
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; }
QJsonObject state = doc.object();
const QString status = state.value("status").toString();
if (status == "committed") return true;
if (status == "rolled_back") {
if (restoredVersion) *restoredVersion = state.value("from_version").toString();
return true;
}
const QString backupDir = state.value("backup_dir").toString();
QJsonArray paths = state.value("changed_paths").toArray();
for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value);
const QString errorCode = state.value("error_code").toString();
const bool mayContainNewFiles = status == "replacing" || status == "replaced"
|| status == "post_verify" || status == "rollback_required"
|| status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed";
bool ok = true;
for (const QJsonValue& value : paths) {
const QString path = value.toString();
if (!safeRelativePath(path)) { ok = false; continue; }
const QString installed = QDir(installDir).filePath(path);
const QString backup = QDir(backupDir).filePath(path);
if (QFile::exists(backup)) {
QDir().mkpath(QFileInfo(installed).path());
if (QFile::exists(installed)) QFile::remove(installed);
if (!QFile::copy(backup, installed)) ok = false;
} else if (mayContainNewFiles) {
if (QFile::exists(installed) && !QFile::remove(installed)) ok = false;
}
}
if (restoredVersion) *restoredVersion = state.value("from_version").toString();
state["status"] = ok ? "rolled_back" : "failed";
QSaveFile output(stateFile);
if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); }
if (!ok && errorMessage) *errorMessage = "failed to restore one or more files";
return ok;
}
+54 -54
View File
@@ -1,54 +1,54 @@
#pragma once
#include <QString>
#include <QStringList>
#include <QJsonObject>
class UpdateTransaction
{
public:
UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId);
bool initialize();
bool resumeExisting(QString* errorMessage = nullptr);
bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths);
bool backupCurrentFiles();
bool installStagedFiles(QString* failedPath = nullptr);
bool markAwaitingBootstrap();
bool markRollbackRequired(const QString& reason);
bool markRolledBack();
bool markPostVerify();
bool rollback(QString* failedPath = nullptr);
bool commit();
bool markFailed(const QString& errorCode, const QString& message);
QString transactionId() const;
QString fromVersion() const;
QString stagingDir() const;
QString backupDir() const;
QStringList obsoletePaths() const;
QString healthFile() const;
static bool recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion = nullptr,
QString* errorMessage = nullptr);
private:
bool writeState(const QString& status, const QString& errorCode = QString(),
const QString& message = QString());
bool copyOverwrite(const QString& source, const QString& destination) const;
static bool safeRelativePath(const QString& path);
QString m_installDir;
QString m_updateDir;
QString m_stateFile;
QString m_transactionId;
QString m_fromVersion;
QString m_toVersion;
int m_manifestId = 0;
QString m_stagingDir;
QString m_backupDir;
QStringList m_changedPaths;
QStringList m_obsoletePaths;
QJsonObject m_state;
};
#pragma once
#include <QString>
#include <QStringList>
#include <QJsonObject>
class UpdateTransaction
{
public:
UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId);
bool initialize();
bool resumeExisting(QString* errorMessage = nullptr);
bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths);
bool backupCurrentFiles();
bool installStagedFiles(QString* failedPath = nullptr);
bool markAwaitingBootstrap();
bool markRollbackRequired(const QString& reason);
bool markRolledBack();
bool markPostVerify();
bool rollback(QString* failedPath = nullptr);
bool commit();
bool markFailed(const QString& errorCode, const QString& message);
QString transactionId() const;
QString fromVersion() const;
QString stagingDir() const;
QString backupDir() const;
QStringList obsoletePaths() const;
QString healthFile() const;
static bool recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion = nullptr,
QString* errorMessage = nullptr);
private:
bool writeState(const QString& status, const QString& errorCode = QString(),
const QString& message = QString());
bool copyOverwrite(const QString& source, const QString& destination) const;
static bool safeRelativePath(const QString& path);
QString m_installDir;
QString m_updateDir;
QString m_stateFile;
QString m_transactionId;
QString m_fromVersion;
QString m_toVersion;
int m_manifestId = 0;
QString m_stagingDir;
QString m_backupDir;
QStringList m_changedPaths;
QStringList m_obsoletePaths;
QJsonObject m_state;
};
+708 -762
View File
File diff suppressed because it is too large Load Diff
+68 -70
View File
@@ -1,77 +1,75 @@
#pragma once
#include <QObject>
#include "HttpHelper.h"
#include "FileHelper.h"
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStringList>
#include <QNetworkReply>
#include <QMap>
#include <QElapsedTimer>
struct FileDownloadItem
{
QString path;
QString url;
QString sha256;
qint64 size;
};
class UpdaterLogic : public QObject
{
Q_OBJECT
public:
explicit UpdaterLogic(QObject* parent = nullptr);
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
bool verifyManifestSignature(const QString& publicKeyPath = ":/simcae/update-client/manifest-public-key.pem") const;
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version);
#pragma once
#include <QObject>
#include "HttpHelper.h"
#include "FileHelper.h"
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStringList>
#include <QNetworkReply>
#include <QMap>
#include <QElapsedTimer>
struct FileDownloadItem
{
QString path;
QString url;
QString sha256;
qint64 size;
};
class UpdaterLogic : public QObject
{
Q_OBJECT
public:
explicit UpdaterLogic(QObject* parent = nullptr);
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version);
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
QString offlineError() const { return m_offlineError; }
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 reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success);
bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
QString calcLocalFileSha256(const QString& filePath) const;
QList<FileDownloadItem> getFileList() const;
QJsonObject getManifest() const { return m_manifest; }
QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const;
QString getManifestText() const { return m_manifestText; }
bool allDownloadSuccess() const { return m_downloadAllOk; }
signals:
void fetchUrlFinished();
void downloadProgress(qint64 receivedBytes, qint64 totalBytes,
const QString& currentPath, double bytesPerSecond);
private:
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
qint64 expectedSize, const QString& resumePartPath);
bool validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
const QString& channel, const QString& targetVer,
int versionId, QList<FileDownloadItem>* fileItems) const;
bool isSafeRelativePath(const QString& path) const;
bool isRuntimeProtectedPath(const QString& path) const;
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const;
HttpHelper m_http;
QString m_serverAddr;
QJsonObject m_manifest;
QString m_manifestText;
QList<FileDownloadItem> m_fileItems;
mutable bool m_manifestSignatureVerified = false;
QString errorString() const { return m_error; }
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 reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success);
bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
QString calcLocalFileSha256(const QString& filePath) const;
QList<FileDownloadItem> getFileList() const;
QJsonObject getManifest() const { return m_manifest; }
QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const;
QString getManifestText() const { return m_manifestText; }
bool allDownloadSuccess() const { return m_downloadAllOk; }
signals:
void fetchUrlFinished();
void downloadProgress(qint64 receivedBytes, qint64 totalBytes,
const QString& currentPath, double bytesPerSecond);
private:
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
qint64 expectedSize, const QString& resumePartPath);
bool isSafeRelativePath(const QString& path) const;
bool isRuntimeProtectedPath(const QString& path) const;
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const;
HttpHelper m_http;
QString m_serverAddr;
QJsonObject m_manifest;
QString m_manifestText;
QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0;
qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0;
qint64 m_downloadTotalBytes = 0;
qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath;
QString m_offlineError;
mutable QString m_error;
QElapsedTimer m_downloadTimer;
};
+342 -341
View File
@@ -1,176 +1,170 @@
#include <QApplication>
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QMessageBox>
#include <QProcess>
#include <QProgressDialog>
#include <QSaveFile>
#include <QStorageInfo>
#include <QThread>
#include <QTranslator>
#include "UpdaterLogic.h"
#include "UpdateTransaction.h"
#include "FileHelper.h"
#include "ConfigHelper.h"
#include "TicketHelper.h"
#include "PolicyHelper.h"
#include "TranslationHelper.h"
#include <QVersionNumber>
#include "UpdateTransaction.h"
#include "FileHelper.h"
#include "ConfigHelper.h"
#include "TicketHelper.h"
#include "PolicyHelper.h"
#include <QVersionNumber>
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
TranslationHelper translation;
translation.installForSystemLocale();
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
UpdaterLogic logic;
QString offlinePackagePath;
QString appId;
QString channel;
QString targetVersion;
int targetVersionId = 0;
QString offlinePackagePath;
QString appId;
QString channel;
QString targetVersion;
int targetVersionId = 0;
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
if (!logic.loadOfflinePackage(offlinePackagePath)) {
QMessageBox::critical(nullptr, QCoreApplication::translate("Updater", "Invalid Offline Package"), logic.offlineError());
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Invalid Offline Package"),
logic.offlineError());
return -1;
}
const QJsonObject packageManifest = logic.getManifest();
appId = packageManifest.value("app_id").toString();
channel = packageManifest.value("channel").toString();
targetVersion = packageManifest.value("version").toString();
targetVersionId = packageManifest.value("manifest_seq").toInt();
const QJsonObject packageManifest = logic.getManifest();
appId = packageManifest.value("app_id").toString();
channel = packageManifest.value("channel").toString();
targetVersion = packageManifest.value("version").toString();
targetVersionId = packageManifest.value("manifest_seq").toInt();
} else {
if (argc < 5) {
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Invalid Updater Arguments"),
QCoreApplication::translate("Updater", "The updater requires online update arguments or an offline update package."));
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Updater Argument Error"),
QCoreApplication::translate("Updater", "The updater is missing online update arguments or an offline update package."));
return -1;
}
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
}
QString bootstrapResult;
for (int i = 5; i < argc; ++i) {
const QString arg = argv[i];
if (arg.startsWith("--bootstrap-resume="))
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
}
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance();
const QString targetDir = config.installRoot();
const QString updateDir = config.updateRoot();
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
}
QString bootstrapResult;
for (int i = 5; i < argc; ++i) {
const QString arg = argv[i];
if (arg.startsWith("--bootstrap-resume="))
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
}
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance();
const QString targetDir = config.installRoot();
const QString updateDir = config.updateRoot();
QDir().mkpath(updateDir);
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
QCoreApplication::translate("Updater", "The update package application or channel does not match this installation."));
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
return -1;
}
QString fromVersion = config.getValue("App", "current_version");
PolicyHelper updatePolicy(runtimeDir);
const bool policyApplicable =
updatePolicy.loadPolicy() && updatePolicy.isValid()
&& updatePolicy.isApplicable(appId, channel, fromVersion)
&& !updatePolicy.isExpired()
&& updatePolicy.isVersionAllowed(targetVersion);
if (!policyApplicable
|| (!offlinePackagePath.isEmpty() && !updatePolicy.isOfflineAllowed()))
{
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Update Denied"),
QCoreApplication::translate(
"Updater",
"The local signed policy is invalid, expired, not applicable to this installation, or does not allow the requested update."));
return -1;
}
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
QVersionNumber::fromString(fromVersion)) < 0
&& !updatePolicy.allowRollback())
{
QMessageBox::critical(
nullptr,
QCoreApplication::translate("Updater", "Downgrade Prohibited"),
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an older version."));
return -1;
}
QString deviceId = config.getValue("Update", "device_id");
if (deviceId.isEmpty()) deviceId = "unknown_device";
QString fromVersion = config.getValue("App", "current_version");
if (!offlinePackagePath.isEmpty()) {
PolicyHelper offlinePolicy(runtimeDir);
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Offline Update Rejected"),
QCoreApplication::translate("Updater", "The local signed policy has expired, forbids offline updates, or does not allow the target version."));
return -1;
}
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
QVersionNumber::fromString(fromVersion)) < 0
&& !offlinePolicy.allowRollback()) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Downgrade Forbidden"),
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an offline package with a lower version."));
return -1;
}
}
QString deviceId = config.getValue("Update", "device_id");
if (deviceId.isEmpty()) deviceId = "unknown_device";
if (!resumingFromBootstrap) {
QString restoredVersion;
QString recoveryError;
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Update Recovery Failed"),
QCoreApplication::translate("Updater", "The previous update did not finish, and the old version could not be restored: %1\nDo not continue running the software. Contact an administrator.")
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.")
.arg(recoveryError));
return -1;
}
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
if (!config.setValue("App", "current_version", restoredVersion)) {
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Update Recovery Failed"),
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Check access to the system state store."));
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory."));
return -1;
}
fromVersion = restoredVersion;
}
}
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing the update..."), QString(), 0, 100);
fromVersion = restoredVersion;
}
}
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing update..."), QString(), 0, 100);
progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
progress.setAutoClose(false);
progress.setValue(2);
progress.show();
QApplication::processEvents();
UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId);
const auto setProgress = [&](int value, const QString& message) {
progress.setValue(value);
progress.setLabelText(message);
QApplication::processEvents();
};
const auto formatBytes = [](qint64 bytes) {
const double value = static_cast<double>(bytes);
if (bytes >= 1024LL * 1024 * 1024)
return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB";
if (bytes >= 1024LL * 1024)
return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB";
if (bytes >= 1024)
return QString::number(value / 1024.0, 'f', 1) + " KB";
return QString::number(bytes) + " B";
};
QObject::connect(&logic, &UpdaterLogic::downloadProgress,
progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
progress.setAutoClose(false);
progress.setValue(2);
progress.show();
QApplication::processEvents();
UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId);
const auto setProgress = [&](int value, const QString& message) {
progress.setValue(value);
progress.setLabelText(message);
QApplication::processEvents();
};
const auto formatBytes = [](qint64 bytes) {
const double value = static_cast<double>(bytes);
if (bytes >= 1024LL * 1024 * 1024)
return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB";
if (bytes >= 1024LL * 1024)
return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB";
if (bytes >= 1024)
return QString::number(value / 1024.0, 'f', 1) + " KB";
return QString::number(bytes) + " B";
};
QObject::connect(&logic, &UpdaterLogic::downloadProgress,
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
const QString fileText = path.isEmpty()
? QCoreApplication::translate("Updater", "Preparing the download...")
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
progress.setValue(value);
progress.setLabelText(QString("%1\n%2 / %3 | %4/s")
.arg(fileText, formatBytes(received), formatBytes(total),
formatBytes(static_cast<qint64>(bytesPerSecond))));
QApplication::processEvents();
});
const auto reportUpdateResult = [&](bool success) {
if (offlinePackagePath.isEmpty())
logic.reportResult(deviceId, fromVersion, targetVersion, success);
};
? QCoreApplication::translate("Updater", "Preparing download...")
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
progress.setValue(value);
progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
.arg(fileText, formatBytes(received), formatBytes(total),
formatBytes(static_cast<qint64>(bytesPerSecond))));
QApplication::processEvents();
});
const auto reportUpdateResult = [&](bool success) {
if (offlinePackagePath.isEmpty())
logic.reportResult(deviceId, fromVersion, targetVersion, success);
};
const auto fail = [&](const QString& title, const QString& message,
const QString& errorCode = QString("update_failed")) {
transaction.markFailed(errorCode, message);
@@ -179,347 +173,354 @@ int main(int argc, char* argv[])
QMessageBox::critical(nullptr, title, message);
return -1;
};
const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
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 QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
const auto configuredName = [&](const QString& key, const QString& fallback) {
return ConfigHelper::executableNameForCurrentPlatform(
config.getValue("Runtime", key), fallback);
};
const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable);
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
const QString launchToken = config.getValue("App", "launch_token");
QString mainStartupError;
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 ticketError;
const QString launchVersion = config.getValue("App", "current_version");
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
&ticketPath, &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;
}
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
const bool started = QProcess::startDetached(mainAppPath, args);
if (!started) QFile::remove(ticketPath);
if (!started) {
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;
};
const auto bootstrapPlanFile = [&]() {
return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt");
};
const auto launchBootstrap = [&](const QString& mode) {
const QStringList args{
bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath,
QString::number(QCoreApplication::applicationPid()), appId, channel,
targetVersion, QString::number(targetVersionId), mode
};
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
};
const auto bootstrapPlanFile = [&]() {
return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt");
};
const auto launchBootstrap = [&](const QString& mode) {
const QStringList args{
bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath,
QString::number(QCoreApplication::applicationPid()), appId, channel,
targetVersion, QString::number(targetVersionId), mode
};
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
};
const auto delegateRollback = [&](const QString& title, const QString& reason) {
FileHelper::killProcess(mainExecutable);
transaction.markRollbackRequired(reason);
progress.setLabelText(QCoreApplication::translate("Updater", "Handing rollback over to Bootstrap..."));
progress.setLabelText(QCoreApplication::translate("Updater", "Delegating rollback to Bootstrap..."));
QApplication::processEvents();
if (launchBootstrap("rollback")) {
progress.close();
return 0;
}
if (launchBootstrap("rollback")) {
progress.close();
return 0;
}
reportUpdateResult(false);
progress.close();
QMessageBox::critical(nullptr, title,
reason + QCoreApplication::translate("Updater", "\n\nBootstrap could not be started to perform the rollback. Do not continue running the software. Contact an administrator."));
reason + QCoreApplication::translate("Updater", "\n\nCannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator."));
return -1;
};
if (resumingFromBootstrap) {
QString resumeError;
if (!transaction.resumeExisting(&resumeError)) {
if (resumingFromBootstrap) {
QString resumeError;
if (!transaction.resumeExisting(&resumeError)) {
reportUpdateResult(false);
progress.close();
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Transaction Resume Failed"),
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
return -1;
}
fromVersion = transaction.fromVersion();
if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode")))
offlinePackagePath = "__bootstrap_resumed_offline__";
if (bootstrapResult == "rolledback") {
const bool stateOk = config.setValue("App", "current_version", fromVersion);
transaction.markRolledBack();
reportUpdateResult(false);
fromVersion = transaction.fromVersion();
if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode")))
offlinePackagePath = "__bootstrap_resumed_offline__";
if (bootstrapResult == "rolledback") {
const bool stateOk = config.setValue("App", "current_version", fromVersion);
transaction.markRolledBack();
reportUpdateResult(false);
if (stateOk) launchMainApp();
progress.close();
QMessageBox::warning(
nullptr,
QMessageBox::warning(nullptr,
QCoreApplication::translate("Updater", "Update Rolled Back"),
stateOk
? QCoreApplication::translate("Updater", "The new version could not be installed or started. The old version was restored and started automatically.")
: QCoreApplication::translate("Updater", "The old files were restored, but the old version number could not be written back. Check permissions on the system state store."));
? QCoreApplication::translate("Updater", "The new version failed to install or start. The old version has been 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."));
return stateOk ? 0 : -1;
}
if (bootstrapResult != "success") {
reportUpdateResult(false);
progress.close();
QMessageBox::critical(
nullptr,
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Automatic Rollback Failed"),
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator."));
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator."));
return -1;
}
} else if (!transaction.initialize()) {
return fail(
QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save its state."),
"transaction_init_failed");
return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save the transaction state."),
"transaction_init_failed");
} else if (!offlinePackagePath.isEmpty()) {
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
return fail(
QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
"offline_marker_failed");
return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
"offline_marker_failed");
}
setProgress(
resumingFromBootstrap ? 72 : 10,
setProgress(resumingFromBootstrap ? 72 : 10,
offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Fetching and validating the version manifest...")
: QCoreApplication::translate("Updater", "Validating the offline update package..."));
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
if (resumingFromBootstrap) {
? QCoreApplication::translate("Updater", "Fetching and verifying version manifest...")
: QCoreApplication::translate("Updater", "Verifying offline update package..."));
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback(
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "The signed manifest cache could not be read after Bootstrap installation."));
} else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId);
}
if (!logic.verifyManifestSignature()) {
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
logic.errorString()));
} else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId);
}
if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap)
return delegateRollback(
QCoreApplication::translate("Updater", "Security Validation Failed"),
QCoreApplication::translate("Updater", "The version manifest signature could not be revalidated after Bootstrap installation."));
return fail(
QCoreApplication::translate("Updater", "Security Validation Failed"),
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Contact an administrator."),
"manifest_signature_invalid");
}
QStringList obsoletePaths;
if (!resumingFromBootstrap && fromVersion != targetVersion) {
UpdaterLogic oldManifestLogic;
if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion)
&& oldManifestLogic.getManifest().value("version").toString() == fromVersion
&& oldManifestLogic.verifyManifestSignature()) {
obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest());
qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths;
} else {
qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety";
}
}
return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"),
withDetails(QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
logic.errorString()),
"manifest_signature_invalid");
}
QStringList obsoletePaths;
if (!resumingFromBootstrap && fromVersion != targetVersion) {
UpdaterLogic oldManifestLogic;
if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion)
&& oldManifestLogic.getManifest().value("version").toString() == fromVersion
&& oldManifestLogic.verifyManifestSignature()) {
obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest());
qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths;
} else {
qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety";
}
}
if (!logic.saveManifestCache(manifestCacheDir)) {
if (resumingFromBootstrap)
return delegateRollback(
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
return fail(
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
"manifest_cache_failed");
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
logic.errorString()),
"manifest_cache_failed");
}
if (!resumingFromBootstrap) {
setProgress(
25,
setProgress(25,
offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
: QCoreApplication::translate("Updater", "Preparing offline package files..."));
if (offlinePackagePath.isEmpty())
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
if (logic.getFileList().isEmpty())
return fail(
QCoreApplication::translate("Updater", "No Update Files"),
QCoreApplication::translate("Updater", "The server returned no version files. The update has stopped."),
"empty_file_list");
QStorageInfo storage(updateDir);
storage.refresh();
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
return fail(QCoreApplication::translate("Updater", "No Files to Update"),
withDetails(QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
logic.errorString()),
"empty_file_list");
QStorageInfo storage(updateDir);
storage.refresh();
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
const qint64 availableBytes = storage.bytesAvailable();
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
return fail(
QCoreApplication::translate("Updater", "Insufficient Disk Space"),
QCoreApplication::translate("Updater", "The update requires at least %1 of free space, but the installation drive has only %2 available.\nThe required space includes downloaded files, the old-version backup, and a safety margin.")
return fail(QCoreApplication::translate("Updater", "Insufficient Disk Space"),
QCoreApplication::translate("Updater",
"The update requires at least %1 of free space, but the installation drive currently has only %2.\n"
"The required space includes downloaded files, old version backups, and a safety margin.")
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
"disk_space_insufficient");
}
const QString stagingDir = transaction.stagingDir();
setProgress(
30,
(offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Downloading and validating %1 version files...")
: QCoreApplication::translate("Updater", "Extracting and validating %1 offline files..."))
.arg(logic.getFileList().size()));
setProgress(30, offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Downloading and verifying %1 version files...").arg(logic.getFileList().size())
: QCoreApplication::translate("Updater", "Extracting and verifying %1 offline files...").arg(logic.getFileList().size()));
if (!offlinePackagePath.isEmpty()) {
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"), logic.offlineError(), "offline_package_invalid");
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"),
logic.offlineError(),
"offline_package_invalid");
} else {
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
logic.reportDownloadResult(appId, channel, targetVersion, false);
return fail(
QCoreApplication::translate("Updater", "Download Failed"),
QCoreApplication::translate("Updater", "Some files could not be downloaded or failed SHA-256 validation. Check the network and try again."),
"download_failed");
return fail(QCoreApplication::translate("Updater", "Download Failed"),
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."),
logic.errorString()),
"download_failed");
}
logic.reportDownloadResult(appId, channel, targetVersion, true);
}
setProgress(58, QCoreApplication::translate("Updater", "Validating the complete version files..."));
setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files..."));
if (!logic.validateLocalFiles(stagingDir, targetDir))
return fail(
QCoreApplication::translate("Updater", "File Validation Failed"),
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
"staging_verify_failed");
QStringList changedPaths;
QDir stagingRoot(stagingDir);
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
while (stagingFiles.hasNext())
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
const QString runtimePrefix = config.runtimeRelativePath();
const QString bootstrapManifestPath = QDir::fromNativeSeparators(
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
return fail(QCoreApplication::translate("Updater", "File Verification Failed"),
withDetails(QCoreApplication::translate("Updater", "The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files."),
logic.errorString()),
"staging_verify_failed");
QStringList changedPaths;
QDir stagingRoot(stagingDir);
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
while (stagingFiles.hasNext())
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
const QString runtimePrefix = config.runtimeRelativePath();
const QString bootstrapManifestPath = QDir::fromNativeSeparators(
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
for (const QString& path : changedPaths) {
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
return fail(
QCoreApplication::translate("Updater", "Bootstrap Cannot Update Itself"),
QCoreApplication::translate("Updater", "This version contains a new %1. Upgrade this component with the installer, then republish the application version.").arg(bootstrapManifestPath),
"bootstrap_self_update_blocked");
return fail(QCoreApplication::translate("Updater", "Bootstrap Cannot Self-Update"),
QCoreApplication::translate("Updater", "This version contains a new %1. Please upgrade this component with the installer, then publish the business version again.")
.arg(bootstrapManifestPath),
"bootstrap_self_update_blocked");
}
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
return fail(
QCoreApplication::translate("Updater", "Transaction Recording Failed"),
QCoreApplication::translate("Updater", "Cannot save the validated-file or pending-deletion lists. The update has stopped."),
"transaction_record_failed");
return fail(QCoreApplication::translate("Updater", "Transaction Recording Failed"),
QCoreApplication::translate("Updater", "Cannot save the list of verified or pending deletion files. The update has stopped."),
"transaction_record_failed");
setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
if (!FileHelper::killProcess(mainExecutable))
return fail(
QCoreApplication::translate("Updater", "Cannot Close Main Application"),
QCoreApplication::translate("Updater", "%1 is still running. Close it manually and try again.").arg(mainExecutable),
"mainapp_close_failed");
return fail(QCoreApplication::translate("Updater", "Cannot Close Main Application"),
QCoreApplication::translate("Updater", "%1 is still running. Please close it manually and try again.").arg(mainExecutable),
"mainapp_close_failed");
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 deletions...")
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 files to be deleted...")
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
if (!transaction.backupCurrentFiles())
return fail(
QCoreApplication::translate("Updater", "Backup Failed"),
QCoreApplication::translate("Updater", "Cannot back up the current version files. The new version has not been installed. Check disk space and directory permissions."),
"backup_failed");
return fail(QCoreApplication::translate("Updater", "Backup Failed"),
QCoreApplication::translate("Updater", "Cannot back up current version files. The new version has not been installed. Please check disk space and directory permissions."),
"backup_failed");
const QString planFile = bootstrapPlanFile();
QSaveFile plan(planFile);
if (!plan.open(QIODevice::WriteOnly))
return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
"bootstrap_plan_failed");
const auto writePlanItem = [&](char operation, const QString& path) {
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
return plan.write(line) == line.size();
};
for (const QString& path : changedPaths) {
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
"bootstrap_plan_failed");
const auto writePlanItem = [&](char operation, const QString& path) {
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
return plan.write(line) == line.size();
};
for (const QString& path : changedPaths) {
if (!writePlanItem('C', path)) {
plan.cancelWriting();
return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
"bootstrap_plan_failed");
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
"bootstrap_plan_failed");
}
}
for (const QString& path : obsoletePaths) {
if (!writePlanItem('D', path)) {
plan.cancelWriting();
return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
"bootstrap_plan_failed");
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
"bootstrap_plan_failed");
}
}
if (!plan.commit() || !transaction.markAwaitingBootstrap())
return fail(
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
"bootstrap_plan_failed");
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
"bootstrap_plan_failed");
setProgress(78, QCoreApplication::translate("Updater", "Handing installation over to Bootstrap..."));
setProgress(78, QCoreApplication::translate("Updater", "Delegating installation to Bootstrap..."));
if (!launchBootstrap("install"))
return fail(
QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
QCoreApplication::translate("Updater", "Cannot start the independent update handoff program: %1").arg(bootstrapPath),
"bootstrap_start_failed");
progress.close();
return 0;
}
setProgress(82, QCoreApplication::translate("Updater", "Validating the Bootstrap installation result..."));
return fail(QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
QCoreApplication::translate("Updater", "Cannot start the standalone update handoff program: %1").arg(bootstrapPath),
"bootstrap_start_failed");
progress.close();
return 0;
}
setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result..."));
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
return delegateRollback(
QCoreApplication::translate("Updater", "Installation Validation Failed"),
QCoreApplication::translate("Updater", "The new version files failed validation after installation."));
return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"),
withDetails(QCoreApplication::translate("Updater", "New version files failed verification after installation. The updater will roll back to the previous version."),
logic.errorString()));
for (const QString& path : transaction.obsoletePaths()) {
if (QFile::exists(QDir(targetDir).filePath(path)))
return delegateRollback(
QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
}
setProgress(89, QCoreApplication::translate("Updater", "Saving the new version state..."));
setProgress(89, QCoreApplication::translate("Updater", "Saving new version state..."));
if (!config.setValue("App", "current_version", targetVersion)
|| config.getValue("App", "current_version") != targetVersion)
return delegateRollback(
QCoreApplication::translate("Updater", "State Save Failed"),
QCoreApplication::translate("Updater", "Cannot save the current version number."));
return delegateRollback(QCoreApplication::translate("Updater", "State Save Failed"),
QCoreApplication::translate("Updater", "Cannot save the current version number."));
const QString healthFile = transaction.healthFile();
QFile::remove(healthFile);
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for a health confirmation..."));
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation..."));
if (!launchMainApp(healthFile))
return delegateRollback(
QCoreApplication::translate("Updater", "Startup Failed"),
QCoreApplication::translate("Updater", "%1 could not be started.").arg(mainExecutable));
QElapsedTimer healthTimer;
healthTimer.start();
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
QApplication::processEvents();
QThread::msleep(100);
return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"),
mainStartupError.isEmpty()
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
: mainStartupError);
QElapsedTimer healthTimer;
healthTimer.start();
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
QApplication::processEvents();
QThread::msleep(100);
}
if (!QFile::exists(healthFile))
return delegateRollback(
QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
QCoreApplication::translate("Updater", "The new version did not complete its startup health confirmation within %1 milliseconds.").arg(healthCheckTimeoutMs));
return delegateRollback(QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
QCoreApplication::translate("Updater", "The new version did not complete startup health confirmation within %1 milliseconds.")
.arg(healthCheckTimeoutMs));
setProgress(99, QCoreApplication::translate("Updater", "Committing the update transaction..."));
setProgress(99, QCoreApplication::translate("Updater", "Committing update transaction..."));
if (!transaction.commit())
return delegateRollback(
QCoreApplication::translate("Updater", "Transaction Commit Failed"),
QCoreApplication::translate("Updater", "The new version started, but the update transaction could not be committed."));
QFile::remove(healthFile);
QFile::remove(bootstrapPlanFile());
return delegateRollback(QCoreApplication::translate("Updater", "Transaction Commit Failed"),
QCoreApplication::translate("Updater", "The new version has started, but the update transaction could not be committed."));
QFile::remove(healthFile);
QFile::remove(bootstrapPlanFile());
reportUpdateResult(true);
progress.setValue(100);
progress.close();
QMessageBox::information(
nullptr,
QMessageBox::information(nullptr,
QCoreApplication::translate("Updater", "Update Complete"),
QCoreApplication::translate("Updater", "The software was updated successfully to %1 and passed the startup health check.").arg(targetVersion));
return 0;
}
QCoreApplication::translate("Updater", "The software has been successfully updated to %1 and passed the startup health check.")
.arg(targetVersion));
return 0;
}
-82
View File
@@ -1,82 +0,0 @@
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
@@ -1,329 +0,0 @@
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
@@ -1,889 +0,0 @@
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()
+21 -26
View File
@@ -1,26 +1,21 @@
{
"schema_version": 1,
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"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",
"initial_state": {
"current_version": "1.0.0",
"license_key": "",
"device_id": "",
"installation_id": ""
}
}
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
+21
View File
@@ -0,0 +1,21 @@
{
"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-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
TwIDAQAB
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX1FSi06eP8XhX4B3Oy6
FzfkTe3FBIg6OjWpT1541LivRTRt9HXZ30nKuIs5itXBzBQPMU8hsfR0MD9nfPG/
NlajfZzqbyxAJlUMeThJiwtyz98wv3VE1hS2Bwuc3mbmtfU0zl/MoPdWrluL3x3C
bt0ylL00rLBuvjgG21zDflVqj3w9CwpKHFsNrSYoI6vOFX9YrRZ9tKcPEkSRPqts
411QkAQLtMiOMIIhNe5aFIL7doCnglx32hJeHNCTUSxjM9VyHUTEj8wKN/6Gy5iP
YZGRafeiJZ32RRIHyssereAg3Xe/3scd+tbFNNYP9xrRhRgDacmkSCBodnbJd4Qc
rQIDAQAB
-----END PUBLIC KEY-----
+3
View File
@@ -0,0 +1,3 @@
{
"api_base_url": "http://192.168.229.128:8000"
}
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/simcae">
<file>server_config.json</file>
</qresource>
</RCC>
-162
View File
@@ -1,162 +0,0 @@
# 更新客户端架构
## 目的
更新客户端强制执行一条受控的产品入口路径,并执行事务性更新,而不会将可编辑的安装文件作为信任边界的一部分。父项目 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
@@ -1,127 +0,0 @@
# 构建和打包
## 所有权
父级 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
@@ -1,138 +0,0 @@
# 配置与安全
## 配置模型
配置分为两个拥有不同所有者的类别:
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
@@ -1,97 +0,0 @@
# 本地化维护
## 政策
英文是生产代码的源语言。用户可见的中文文本只能出现在本地化资源中。生产代码中的 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
@@ -1,129 +0,0 @@
# 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
@@ -0,0 +1,176 @@
客户端国际化说明
================
这份文档说明 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
@@ -0,0 +1,5 @@
<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
@@ -0,0 +1,59 @@
客户端脚本说明
==============
本目录保存 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
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,173 @@
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
@@ -0,0 +1,234 @@
#!/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
@@ -0,0 +1,159 @@
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
@@ -0,0 +1,154 @@
#!/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
@@ -1,822 +0,0 @@
<?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>