Compare commits

...

8 Commits

55 changed files with 7175 additions and 6382 deletions
+6
View File
@@ -0,0 +1,6 @@
root = true
[*]
charset = utf-8-bom
trim_trailing_whitespace = true
insert_final_newline = true
+3
View File
@@ -22,6 +22,8 @@ Desktop.ini
*.pdf *.pdf
*.doc *.doc
*.docx *.docx
private/
pdf_requirements.txt
# C/C++ generated artifacts # C/C++ generated artifacts
*.obj *.obj
@@ -49,6 +51,7 @@ CMakeUserPresets.json
Testing/ Testing/
# Third-party/business binary drops and generated packages # Third-party/business binary drops and generated packages
thirdparty/
App/ App/
dist/ dist/
*.zip *.zip
+17 -3
View File
@@ -1,6 +1,20 @@
project(Bootstrap LANGUAGES CXX) project(Bootstrap LANGUAGES CXX)
add_executable(Bootstrap WIN32 main.cpp) 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_compile_features(Bootstrap PRIVATE cxx_std_17)
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE) target_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets)
target_link_libraries(Bootstrap PRIVATE shell32)
if(WIN32)
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
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()
+151 -138
View File
@@ -1,190 +1,203 @@
#include <windows.h> #include <QApplication>
#include <shellapi.h> #include <QCoreApplication>
#include <filesystem> #include <QDir>
#include <chrono> #include <QFile>
#include <cstdlib> #include <QFileInfo>
#include <fstream> #include <QMessageBox>
#include <string> #include <QProcess>
#include <thread> #include <QTextStream>
#include <vector> #include <QThread>
#include <QTranslator>
#include <QVector>
namespace fs = std::filesystem; struct PlanItem
static std::wstring quote(const std::wstring& value)
{ {
std::wstring result = L"\""; QChar operation;
unsigned backslashes = 0; QString relativePath;
for (wchar_t ch : value) { };
if (ch == L'\\') { ++backslashes; continue; }
if (ch == L'\"') { static void showError(const QString& message)
result.append(backslashes * 2 + 1, L'\\'); {
result.push_back(L'\"'); QMessageBox::critical(nullptr,
backslashes = 0; QApplication::translate("Bootstrap", "Update Handoff Failed"),
continue; message);
}
result.append(backslashes, L'\\');
backslashes = 0;
result.push_back(ch);
}
result.append(backslashes * 2, L'\\');
result.push_back(L'\"');
return result;
} }
static std::wstring fromUtf8(const std::string& value) static QString cleanRelativePath(const QString& value)
{ {
if (value.empty()) return {}; QString path = QDir::fromNativeSeparators(value.trimmed());
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), if (path.isEmpty())
static_cast<int>(value.size()), nullptr, 0); return {};
if (size <= 0) return {};
std::wstring result(size, L'\0'); path = QDir::cleanPath(path);
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), if (path == "." || path == ".." || path.startsWith("../")
static_cast<int>(value.size()), result.data(), size); || path.contains("/../") || QDir::isAbsolutePath(path)
return result; || path.contains(':')) {
return {};
}
return path;
} }
static bool safeRelative(const fs::path& path) static QString joinPath(const QString& root, const QString& relativePath)
{ {
if (path.empty() || path.is_absolute() || path.has_root_name()) return false; return QDir(root).filePath(relativePath);
for (const auto& part : path) }
if (part == L"..") return false;
static bool removeWithRetry(const QString& path)
{
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
for (int i = 0; i < 100; ++i) {
if (!QFileInfo::exists(path))
return true; return true;
if (QFile::remove(path))
return true;
QThread::msleep(100);
}
return !QFileInfo::exists(path);
} }
static bool copyWithRetry(const fs::path& source, const fs::path& destination) static bool copyWithRetry(const QString& source, const QString& destination)
{ {
std::error_code ec; QDir().mkpath(QFileInfo(destination).absolutePath());
fs::create_directories(destination.parent_path(), ec);
for (int i = 0; i < 100; ++i) { for (int i = 0; i < 100; ++i) {
ec.clear(); QFile::remove(destination);
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec); if (QFile::copy(source, destination))
if (!ec) return true; return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); QThread::msleep(100);
} }
return false; return false;
} }
static bool removeWithRetry(const fs::path& path) static bool rollback(const QString& installDir, const QString& backupDir,
{ const QVector<QString>& paths)
std::error_code ec;
for (int i = 0; i < 100; ++i) {
ec.clear();
if (!fs::exists(path, ec)) return !ec;
if (fs::remove(path, ec)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
static bool rollback(const fs::path& installDir, const fs::path& backupDir,
const std::vector<fs::path>& paths)
{ {
bool success = true; bool success = true;
for (const auto& relative : paths) { for (const QString& relativePath : paths) {
const fs::path destination = installDir / relative; const QString destination = joinPath(installDir, relativePath);
const fs::path backup = backupDir / relative; const QString backup = joinPath(backupDir, relativePath);
std::error_code ec; if (QFileInfo::exists(backup)) {
if (fs::exists(backup, ec)) { if (!copyWithRetry(backup, destination))
if (!copyWithRetry(backup, destination)) success = false; success = false;
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) { } else if (QFileInfo::exists(destination) && !removeWithRetry(destination)) {
success = false; success = false;
} }
} }
return success; return success;
} }
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs, static bool launchUpdater(const QString& updater, const QStringList& updateArgs,
const std::wstring& result) const QString& result)
{ {
std::wstring command = quote(updater); QStringList args = updateArgs;
for (const auto& arg : updateArgs) command += L" " + quote(arg); args.append(QStringLiteral("--bootstrap-resume=%1").arg(result));
command += L" " + quote(L"--bootstrap-resume=" + result); return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath());
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;
} }
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths)
{ {
int argc = 0; QFile file(planFile);
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
if (!argv || argc < 11) { return false;
MessageBoxW(nullptr, L"Bootstrap 参数不完整。", L"更新接管失败", MB_ICONERROR);
if (argv) LocalFree(argv); 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; 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)) { const QString planFile = arguments.at(1);
WaitForSingleObject(process, 30000); const QString installDir = arguments.at(2);
CloseHandle(process); 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");
struct PlanItem { wchar_t operation; fs::path relative; }; QThread::msleep(500);
std::ifstream input(planFile, std::ios::binary);
std::vector<PlanItem> items; QVector<PlanItem> items;
std::vector<fs::path> paths; QVector<QString> paths;
std::string line; if (!readPlan(planFile, &items, &paths)) {
while (std::getline(input, line)) { showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid."));
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR);
return 3; return 3;
} }
const fs::path relative(fromUtf8(line.substr(2)));
if (!safeRelative(relative)) {
MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR);
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; bool rolledBack = false;
fs::path failedPath; QString failedPath;
if (mode == L"rollback") { if (mode == QStringLiteral("rollback")) {
rolledBack = success && rollback(installDir, backupDir, paths); rolledBack = rollback(installDir, backupDir, paths);
success = false; success = false;
} else if (success) { } else {
for (const auto& item : items) { // Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
const fs::path& relative = item.relative; // 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) { for (const PlanItem& item : items) {
const QString& relativePath = item.relativePath;
if (isBootstrapSelfPath(relativePath)) {
success = false; success = false;
failedPath = relative; failedPath = relativePath;
break; break;
} }
const bool itemOk = item.operation == L'C'
? copyWithRetry(stagingDir / relative, installDir / relative) const bool itemOk = item.operation == QLatin1Char('C')
: removeWithRetry(installDir / relative); ? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath))
: removeWithRetry(joinPath(installDir, relativePath));
if (!itemOk) { if (!itemOk) {
success = false; success = false;
failedPath = relative; failedPath = relativePath;
break; 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)) { if (!launchUpdater(updater, updateArgs, result)) {
std::wstring message = L"无法重新启动 Updater.exe。"; QString message = QApplication::translate("Bootstrap", "Cannot restart Updater.");
if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring(); if (!failedPath.isEmpty()) {
MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR); message += QApplication::translate("Bootstrap", "\nFailed file: %1")
.arg(failedPath);
}
showError(message);
return 4; return 4;
} }
return (success || rolledBack) ? 0 : 5; return (success || rolledBack) ? 0 : 5;
+111 -20
View File
@@ -1,44 +1,128 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(ClientAll LANGUAGES C CXX) project(ClientAll LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(MSVC) 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) add_compile_options(/utf-8)
endif() endif()
# Qt全局配置
# Qt global config. Qt is intentionally not hard-coded here; configure it with
# CMake-recognized environment variables such as CMAKE_PREFIX_PATH or Qt5_DIR.
set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets) find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
# ========== OpenSSL 手动配置(完全抛弃find_package ==========
set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64") get_target_property(QT_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION)
set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include") get_filename_component(QT_BIN_DIR "${QT_QMAKE_EXECUTABLE}" DIRECTORY)
set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd") find_program(QT_LRELEASE_EXECUTABLE NAMES lrelease lrelease.exe HINTS "${QT_BIN_DIR}" REQUIRED)
set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD") find_program(QT_LUPDATE_EXECUTABLE NAMES lupdate lupdate.exe HINTS "${QT_BIN_DIR}" REQUIRED)
# 全局宏,所有子项目统一启用OpenSSL
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"
)
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_custom_target(update_client_translations ALL
DEPENDS "${TRANSLATION_QM}"
)
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) add_compile_definitions(HAVE_OPENSSL=1)
# ==========================================================
# 统一输出目录 # 统一输出目录。Visual Studio Release 实际输出到 out/bin/ReleaseLinux 单独输出到 out/linux/bin。
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) if(UNIX AND NOT APPLE)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out/linux)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) else()
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库 set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out)
endif()
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
# Bootstrap 使用 Qt 实现跨平台更新交接,避免直接依赖 Win32 API。
add_subdirectory(Bootstrap) add_subdirectory(Bootstrap)
# 先编译公共库 # 先编译公共库
add_subdirectory(Common) add_subdirectory(Common)
# 再编译三个业务程序 # 再编译三个业务程序
add_subdirectory(Launcher) add_subdirectory(Launcher)
add_subdirectory(Updater) add_subdirectory(Updater)
add_subdirectory(MainApp) add_subdirectory(MainApp)
# 默认启动项目 # 默认启动项目
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# Copy client.ini and config directory to output bin folder
# 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") set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
endif()
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config") set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem") set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移 # app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config") file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
@@ -46,10 +130,11 @@ if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${IN
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
endif() endif()
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat) foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}") 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) configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
endif() endif()
endforeach() endforeach()
# 编译阶段同步静态配置资源 # 编译阶段同步静态配置资源
add_custom_target(copy_client_resources ALL 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}
@@ -61,10 +146,16 @@ add_custom_target(copy_client_resources ALL
add_dependencies(Launcher copy_client_resources) add_dependencies(Launcher copy_client_resources)
add_dependencies(Updater copy_client_resources) add_dependencies(Updater copy_client_resources)
add_dependencies(MainApp copy_client_resources) add_dependencies(MainApp copy_client_resources)
add_dependencies(Launcher update_client_translations)
add_dependencies(Updater update_client_translations)
add_dependencies(MainApp update_client_translations)
add_dependencies(Bootstrap update_client_translations)
# Install rules for packaging # Install rules for packaging
if(EXISTS ${CONFIG_SOURCE_DIR}) if(EXISTS "${APP_CONFIG_SOURCE_FILE}")
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config) install(FILES "${APP_CONFIG_SOURCE_FILE}" DESTINATION bin/config RENAME app_config.json)
endif() endif()
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE}) if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin) install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
endif() 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"
}
]
}
+5 -7
View File
@@ -1,5 +1,4 @@
project(Common LANGUAGES C CXX) project(Common LANGUAGES C CXX)
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
set(SRC set(SRC
HttpHelper.h HttpHelper.h
@@ -27,12 +26,11 @@ target_include_directories(Common
PRIVATE ${OPENSSL_INC} PRIVATE ${OPENSSL_INC}
) )
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
target_link_directories(Common INTERFACE
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
)
target_link_libraries(Common target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
INTERFACE libssl.lib libcrypto.lib PUBLIC ${SIMCAE_OPENSSL_LINK_LIBRARIES}
) )
if(WIN32)
target_link_libraries(Common INTERFACE shell32)
endif()
+737 -55
View File
@@ -1,13 +1,327 @@
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QByteArray>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue>
#include <QMessageBox>
#include <QSaveFile> #include <QSaveFile>
#include <QSettings> #include <QSettings>
#include <QStandardPaths>
#include <QStringList>
#include <QDebug> #include <QDebug>
#include <string>
#ifdef Q_OS_WIN
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <shellapi.h>
#endif
namespace
{
const QString kElevatedConfigSetFlag = QStringLiteral("--simcae-config-set");
const QString kElevatedFileWriteFlag = QStringLiteral("--simcae-file-write");
const QString kElevatedFileRemoveFlag = QStringLiteral("--simcae-file-remove");
const QString kConfigPathPrefix = QStringLiteral("--config-path-b64=");
const QString kConfigKeyPrefix = QStringLiteral("--config-key-b64=");
const QString kConfigValuePrefix = QStringLiteral("--config-value-b64=");
const QString kFilePathPrefix = QStringLiteral("--file-path-b64=");
const QString kFileDataPrefix = QStringLiteral("--file-data-b64=");
const QString kRegistryOrganization = QStringLiteral("Marsco");
const QString kRegistryApplication = QStringLiteral("UpdateClientSDK");
const QString kRegistryInstallationsGroup = QStringLiteral("installations");
const QString kRegistryConfigGroup = QStringLiteral("config");
const QString kRegistryMetaGroup = QStringLiteral("_meta");
const QString kRegistrySourceHashKey = QStringLiteral("source_sha256");
const QString kRegistrySourcePathKey = QStringLiteral("source_path");
const QString kRegistryRuntimeRootKey = QStringLiteral("runtime_root");
const QString kRegistryImportedAtKey = QStringLiteral("imported_at_utc");
const QString kEmbeddedServerConfigPath = QStringLiteral(":/simcae/server_config.json");
const QString kApiBaseUrlKey = QStringLiteral("api_base_url");
// 需要提权写入时,子进程参数统一用 Base64Url 编码。
// 这样可以避免 Windows 路径、中文、空格或换行在 ShellExecute 参数传递中被截断或误解析。
QString encodeArgument(const QString& value)
{
return QString::fromLatin1(value.toUtf8().toBase64(
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
QString encodeBytes(const QByteArray& value)
{
return QString::fromLatin1(value.toBase64(
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
QString decodeArgument(const QString& value)
{
return QString::fromUtf8(QByteArray::fromBase64(value.toLatin1(),
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
QByteArray decodeBytes(const QString& value)
{
return QByteArray::fromBase64(value.toLatin1(),
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
}
QString findArgumentValue(const QStringList& arguments, const QString& prefix)
{
for (const QString& argument : arguments)
if (argument.startsWith(prefix))
return argument.mid(prefix.size());
return QString();
}
bool writeBytesToFile(const QString& path, const QByteArray& bytes, QString* errorMessage)
{
QDir dir(QFileInfo(path).path());
if (!dir.exists() && !dir.mkpath("."))
{
if (errorMessage)
*errorMessage = QString("Cannot create directory: %1").arg(dir.path());
return false;
}
QSaveFile output(path);
if (!output.open(QIODevice::WriteOnly))
{
if (errorMessage)
*errorMessage = QString("Cannot open file for writing: %1").arg(output.errorString());
return false;
}
if (output.write(bytes) != bytes.size())
{
if (errorMessage)
*errorMessage = QString("Cannot write full file: %1").arg(output.errorString());
output.cancelWriting();
return false;
}
if (!output.commit())
{
if (errorMessage)
*errorMessage = QString("Cannot commit file: %1").arg(output.errorString());
return false;
}
if (errorMessage)
errorMessage->clear();
return true;
}
bool removeFile(const QString& path, QString* errorMessage)
{
if (!QFile::exists(path))
{
if (errorMessage)
errorMessage->clear();
return true;
}
if (QFile::remove(path))
{
if (errorMessage)
errorMessage->clear();
return true;
}
if (errorMessage)
*errorMessage = QString("Cannot remove file: %1").arg(path);
return false;
}
bool writeConfigValueToFile(const QString& configPath, const QString& key,
const QString& value, QString* errorMessage)
{
QFile input(configPath);
QJsonObject config;
if (input.exists())
{
if (!input.open(QIODevice::ReadOnly))
{
if (errorMessage)
*errorMessage = QString("Cannot open config for reading: %1").arg(input.errorString());
return false;
}
QJsonParseError parseError;
const QByteArray raw = input.readAll();
input.close();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
if (errorMessage)
*errorMessage = QString("Invalid config JSON: %1").arg(parseError.errorString());
return false;
}
config = document.object();
}
config.insert(key, value);
QDir dir(QFileInfo(configPath).path());
if (!dir.exists() && !dir.mkpath("."))
{
if (errorMessage)
*errorMessage = QString("Cannot create config directory: %1").arg(dir.path());
return false;
}
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
return writeBytesToFile(configPath, payload, errorMessage);
}
bool isRegistryManagedConfigKey(const QString& key)
{
// 服务端地址是编译期 qrc 配置,不进入注册表。
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
return key != kApiBaseUrlKey;
}
bool isPathInsideDirectory(const QString& path, const QString& directory)
{
if (path.isEmpty() || directory.isEmpty())
return false;
QString normalizedPath = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
QString normalizedDirectory = QDir::cleanPath(QFileInfo(directory).absoluteFilePath());
#ifdef Q_OS_WIN
normalizedPath = normalizedPath.toLower();
normalizedDirectory = normalizedDirectory.toLower();
#endif
return normalizedPath == normalizedDirectory
|| normalizedPath.startsWith(normalizedDirectory + QDir::separator());
}
bool isUserDataPath(const QString& path)
{
return isPathInsideDirectory(path, ConfigHelper::instance().dataRoot());
}
QJsonObject registryManagedConfigObject(const QJsonObject& source)
{
QJsonObject result;
for (auto it = source.constBegin(); it != source.constEnd(); ++it)
{
if (isRegistryManagedConfigKey(it.key()))
result.insert(it.key(), it.value());
}
return result;
}
#ifdef Q_OS_WIN
QString windowsErrorMessage(DWORD errorCode)
{
if (errorCode == ERROR_CANCELLED)
return QCoreApplication::translate("ConfigHelper", "The user canceled the administrator permission confirmation.");
return QCoreApplication::translate("ConfigHelper", "Windows error %1").arg(errorCode);
}
bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetPath,
const QString& originalError, QString* errorMessage)
{
// 安装到 C:\Program Files 等目录时,普通用户不能直接修改配置或运行态文件。
// 这里不让主进程一直以管理员运行,而是在确实需要写入时临时拉起自身完成单次写入。
const QMessageBox::StandardButton choice = QMessageBox::question(
nullptr,
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
QCoreApplication::translate("ConfigHelper", "The current installation directory requires administrator permission to save configuration.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.")
.arg(QDir::toNativeSeparators(targetPath), originalError),
QMessageBox::Ok | QMessageBox::Cancel,
QMessageBox::Ok);
if (choice != QMessageBox::Ok)
{
if (errorMessage)
*errorMessage = QCoreApplication::translate("ConfigHelper", "The user canceled the administrator permission request.");
return false;
}
const QString executable = QCoreApplication::applicationFilePath();
if (executable.isEmpty() || !QFileInfo::exists(executable))
{
if (errorMessage)
*errorMessage = QStringLiteral("Cannot locate current executable for elevated config write.");
return false;
}
const QString parameters = arguments.join(QLatin1Char(' '));
const QString workingDir = QFileInfo(executable).absolutePath();
std::wstring verb = L"runas";
std::wstring file = executable.toStdWString();
std::wstring params = parameters.toStdWString();
std::wstring directory = workingDir.toStdWString();
SHELLEXECUTEINFOW info{};
info.cbSize = sizeof(info);
info.fMask = SEE_MASK_NOCLOSEPROCESS;
info.lpVerb = verb.c_str();
info.lpFile = file.c_str();
info.lpParameters = params.c_str();
info.lpDirectory = directory.c_str();
info.nShow = SW_HIDE;
if (!ShellExecuteExW(&info))
{
const DWORD err = GetLastError();
if (errorMessage)
*errorMessage = QStringLiteral("Cannot request administrator permission: %1").arg(windowsErrorMessage(err));
return false;
}
WaitForSingleObject(info.hProcess, INFINITE);
DWORD exitCode = 1;
GetExitCodeProcess(info.hProcess, &exitCode);
CloseHandle(info.hProcess);
if (exitCode != 0)
{
if (errorMessage)
*errorMessage = QStringLiteral("Elevated config write failed with exit code %1.").arg(exitCode);
return false;
}
if (errorMessage)
errorMessage->clear();
return true;
}
bool writeConfigValueWithElevation(const QString& configPath, const QString& key,
const QString& value, const QString& originalError,
QString* errorMessage)
{
const QStringList arguments{
kElevatedConfigSetFlag,
kConfigPathPrefix + encodeArgument(configPath),
kConfigKeyPrefix + encodeArgument(key),
kConfigValuePrefix + encodeArgument(value)
};
return runElevatedSelfCommand(arguments, configPath, originalError, errorMessage);
}
bool writeBytesWithElevation(const QString& path, const QByteArray& bytes,
const QString& originalError, QString* errorMessage)
{
const QStringList arguments{
kElevatedFileWriteFlag,
kFilePathPrefix + encodeArgument(path),
kFileDataPrefix + encodeBytes(bytes)
};
return runElevatedSelfCommand(arguments, path, originalError, errorMessage);
}
bool removeFileWithElevation(const QString& path, const QString& originalError, QString* errorMessage)
{
const QStringList arguments{
kElevatedFileRemoveFlag,
kFilePathPrefix + encodeArgument(path)
};
return runElevatedSelfCommand(arguments, path, originalError, errorMessage);
}
#endif
}
ConfigHelper& ConfigHelper::instance() ConfigHelper& ConfigHelper::instance()
{ {
@@ -15,12 +329,159 @@ ConfigHelper& ConfigHelper::instance()
return obj; return obj;
} }
QString ConfigHelper::executableNameForCurrentPlatform(const QString& configuredValue,
const QString& fallbackBaseName)
{
QString name = configuredValue.trimmed();
if (name.isEmpty())
name = fallbackBaseName.trimmed();
#ifdef Q_OS_WIN
const QString fileName = QFileInfo(name).fileName();
if (!fileName.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive)
&& QFileInfo(fileName).suffix().isEmpty()) {
name += QStringLiteral(".exe");
}
#else
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
name.chop(4);
#endif
return name;
}
int ConfigHelper::runElevatedWriteCommandIfRequested()
{
const QStringList arguments = QCoreApplication::arguments();
if (arguments.contains(kElevatedConfigSetFlag))
{
const QString configPath = decodeArgument(findArgumentValue(arguments, kConfigPathPrefix));
const QString key = decodeArgument(findArgumentValue(arguments, kConfigKeyPrefix));
const QString value = decodeArgument(findArgumentValue(arguments, kConfigValuePrefix));
if (configPath.isEmpty() || key.isEmpty())
{
qWarning() << "Elevated config write arguments are incomplete.";
return 2;
}
QString errorMessage;
if (!writeConfigValueToFile(configPath, key, value, &errorMessage))
{
qWarning() << "Elevated config write failed:" << errorMessage;
return 3;
}
return 0;
}
if (arguments.contains(kElevatedFileWriteFlag))
{
const QString path = decodeArgument(findArgumentValue(arguments, kFilePathPrefix));
const QByteArray bytes = decodeBytes(findArgumentValue(arguments, kFileDataPrefix));
if (path.isEmpty())
{
qWarning() << "Elevated file write arguments are incomplete.";
return 2;
}
QString errorMessage;
if (!writeBytesToFile(path, bytes, &errorMessage))
{
qWarning() << "Elevated file write failed:" << errorMessage;
return 3;
}
return 0;
}
if (arguments.contains(kElevatedFileRemoveFlag))
{
const QString path = decodeArgument(findArgumentValue(arguments, kFilePathPrefix));
if (path.isEmpty())
{
qWarning() << "Elevated file remove arguments are incomplete.";
return 2;
}
QString errorMessage;
if (!removeFile(path, &errorMessage))
{
qWarning() << "Elevated file remove failed:" << errorMessage;
return 3;
}
return 0;
}
return -1;
}
bool ConfigHelper::writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
QString* errorMessage)
{
QString localError;
if (writeBytesToFile(path, data, &localError))
{
if (errorMessage)
errorMessage->clear();
return true;
}
#ifdef Q_OS_WIN
if (isUserDataPath(path))
{
if (errorMessage)
*errorMessage = localError;
return false;
}
if (data.size() > 24 * 1024)
{
if (errorMessage)
*errorMessage = QString("File is too large for elevated inline write: %1 bytes. Original error: %2")
.arg(data.size()).arg(localError);
return false;
}
return writeBytesWithElevation(path, data, localError, errorMessage);
#else
if (errorMessage)
*errorMessage = localError;
return false;
#endif
}
bool ConfigHelper::removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage)
{
QString localError;
if (removeFile(path, &localError))
{
if (errorMessage)
errorMessage->clear();
return true;
}
#ifdef Q_OS_WIN
if (isUserDataPath(path))
{
if (errorMessage)
*errorMessage = localError;
return false;
}
return removeFileWithElevation(path, localError, errorMessage);
#else
if (errorMessage)
*errorMessage = localError;
return false;
#endif
}
ConfigHelper::ConfigHelper() ConfigHelper::ConfigHelper()
{ {
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json"; m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
m_registryInstallId = QString::fromLatin1(QCryptographicHash::hash(
QDir::cleanPath(QApplication::applicationDirPath()).toUtf8(),
QCryptographicHash::Sha256).toHex());
migrateLegacyIniIfNeeded(); migrateLegacyIniIfNeeded();
syncRegistryFromConfigFileIfChanged();
removeRegistryValue(kApiBaseUrlKey);
qDebug() << "Loading app config path:" << m_configPath; qDebug() << "Loading app config path:" << m_configPath;
qDebug() << "File exists?" << QFile::exists(m_configPath); qDebug() << "File exists?" << QFile::exists(m_configPath);
qDebug() << "Registry installation id:" << m_registryInstallId;
} }
QString ConfigHelper::configPath() const QString ConfigHelper::configPath() const
@@ -41,9 +502,38 @@ QString ConfigHelper::runtimeRoot() const
return QDir::cleanPath(QApplication::applicationDirPath()); return QDir::cleanPath(QApplication::applicationDirPath());
} }
QString ConfigHelper::dataRoot() const
{
QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
if (base.isEmpty())
base = QDir::homePath();
return QDir::cleanPath(QDir(base).filePath(
QStringLiteral("Marsco/UpdateClientSDK/installations/%1").arg(m_registryInstallId)));
}
QString ConfigHelper::dataConfigDir() const
{
return QDir(dataRoot()).filePath(QStringLiteral("config"));
}
QString ConfigHelper::clientIdentityPath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("client_identity.dat"));
}
QString ConfigHelper::policyPath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("version_policy.dat"));
}
QString ConfigHelper::localStatePath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("local_state.json"));
}
QString ConfigHelper::updateRoot() const QString ConfigHelper::updateRoot() const
{ {
return QDir(runtimeRoot()).filePath("update"); return QDir(dataRoot()).filePath("update");
} }
QString ConfigHelper::runtimeRelativePath() const QString ConfigHelper::runtimeRelativePath() const
@@ -62,9 +552,227 @@ QString ConfigHelper::lastError() const
return m_error; return m_error;
} }
QString ConfigHelper::getValue(const QString& section, const QString& key) const void ConfigHelper::enterRegistryGroup(QSettings& settings) const
{ {
Q_UNUSED(section); settings.beginGroup(kRegistryInstallationsGroup);
settings.beginGroup(m_registryInstallId);
}
bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
{
QFile file(m_configPath);
if (!file.exists())
return true;
if (!file.open(QIODevice::ReadOnly))
{
m_error = QStringLiteral("Cannot open config for reading: %1").arg(file.errorString());
return false;
}
QJsonParseError parseError;
const QByteArray raw = file.readAll();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
m_error = QStringLiteral("Invalid config JSON: %1").arg(parseError.errorString());
return false;
}
const QJsonObject config = registryManagedConfigObject(document.object());
const QByteArray normalizedConfig = QJsonDocument(config).toJson(QJsonDocument::Compact);
const QString sourceHash = QString::fromLatin1(
QCryptographicHash::hash(normalizedConfig, QCryptographicHash::Sha256).toHex());
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
kRegistryOrganization, kRegistryApplication);
enterRegistryGroup(settings);
settings.beginGroup(kRegistryMetaGroup);
const QString previousHash = settings.value(kRegistrySourceHashKey).toString();
settings.endGroup();
if (previousHash == sourceHash)
return true;
if (config.isEmpty())
{
settings.beginGroup(kRegistryMetaGroup);
settings.setValue(kRegistrySourceHashKey, sourceHash);
settings.setValue(kRegistrySourcePathKey, QDir::toNativeSeparators(QFileInfo(m_configPath).absoluteFilePath()));
settings.setValue(kRegistryRuntimeRootKey, QDir::toNativeSeparators(QApplication::applicationDirPath()));
settings.setValue(kRegistryImportedAtKey, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
settings.endGroup();
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot sync empty app_config.json marker to registry.");
return false;
}
m_error.clear();
qDebug() << "app_config.json is empty; keeping existing registry configuration.";
return true;
}
const bool configChangedAfterPreviousImport = !previousHash.isEmpty();
settings.beginGroup(kRegistryConfigGroup);
settings.remove(QString());
for (auto it = config.constBegin(); it != config.constEnd(); ++it)
settings.setValue(it.key(), it.value().toVariant());
settings.endGroup();
settings.beginGroup(kRegistryMetaGroup);
settings.setValue(kRegistrySourceHashKey, sourceHash);
settings.setValue(kRegistrySourcePathKey, QDir::toNativeSeparators(QFileInfo(m_configPath).absoluteFilePath()));
settings.setValue(kRegistryRuntimeRootKey, QDir::toNativeSeparators(QApplication::applicationDirPath()));
settings.setValue(kRegistryImportedAtKey, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
settings.endGroup();
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot sync config to registry.");
return false;
}
m_error.clear();
qDebug() << "Synced app_config.json to registry installation id:" << m_registryInstallId;
if (configChangedAfterPreviousImport)
{
const QStringList staleFiles{
clientIdentityPath(),
policyPath(),
localStatePath()
};
for (const QString& staleFile : staleFiles)
{
QString removeError;
if (!removeFile(staleFile, &removeError))
{
m_error = QStringLiteral("Cannot remove stale runtime file after config change: %1. %2")
.arg(staleFile, removeError);
return false;
}
}
qDebug() << "Removed stale client identity, policy and local state after app_config.json changed.";
}
if (!config.isEmpty() && !sanitizeConfigFileAfterImport(settings))
{
qWarning() << "Cannot sanitize app_config.json after registry import:" << m_error;
return true;
}
return true;
}
bool ConfigHelper::sanitizeConfigFileAfterImport(QSettings& settings)
{
const QJsonObject emptyConfig;
const QByteArray normalizedEmptyConfig = QJsonDocument(emptyConfig).toJson(QJsonDocument::Compact);
const QByteArray emptyFileBytes = QJsonDocument(emptyConfig).toJson(QJsonDocument::Indented);
const QString sanitizedHash = QString::fromLatin1(
QCryptographicHash::hash(normalizedEmptyConfig, QCryptographicHash::Sha256).toHex());
QString writeError;
if (!writeBytesToFile(m_configPath, emptyFileBytes, &writeError))
{
// 清空 app_config.json 只是为了减少明文配置暴露,不是启动必需步骤。
// 如果安装目录或文件只读,不再为了清空源配置弹 UAC;运行配置已经写入 HKCU 注册表。
m_error = QStringLiteral("Cannot clear app_config.json after registry import without elevation: %1").arg(writeError);
return false;
}
settings.beginGroup(kRegistryMetaGroup);
settings.setValue(kRegistrySourceHashKey, sanitizedHash);
settings.setValue(kRegistrySourcePathKey, QDir::toNativeSeparators(QFileInfo(m_configPath).absoluteFilePath()));
settings.setValue(kRegistryRuntimeRootKey, QDir::toNativeSeparators(QApplication::applicationDirPath()));
settings.setValue(kRegistryImportedAtKey, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
settings.endGroup();
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot update registry source hash after clearing app_config.json.");
return false;
}
m_error.clear();
qDebug() << "Cleared app_config.json after importing configuration to registry.";
return true;
}
bool ConfigHelper::removeRegistryValue(const QString& key) const
{
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
kRegistryOrganization, kRegistryApplication);
enterRegistryGroup(settings);
settings.beginGroup(kRegistryConfigGroup);
settings.remove(key);
settings.endGroup();
settings.sync();
return settings.status() == QSettings::NoError;
}
QString ConfigHelper::readEmbeddedValue(const QString& key) const
{
if (key != kApiBaseUrlKey)
return QString();
QFile file(kEmbeddedServerConfigPath);
if (!file.open(QIODevice::ReadOnly))
return QString();
QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !document.isObject())
return QString();
return document.object().value(key).toVariant().toString().trimmed();
}
bool ConfigHelper::readRegistryValue(const QString& key, QString* value) const
{
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
kRegistryOrganization, kRegistryApplication);
enterRegistryGroup(settings);
settings.beginGroup(kRegistryConfigGroup);
if (!settings.contains(key))
{
settings.endGroup();
return false;
}
if (value)
*value = settings.value(key).toString();
settings.endGroup();
return true;
}
bool ConfigHelper::writeRegistryValue(const QString& key, const QString& value)
{
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
kRegistryOrganization, kRegistryApplication);
enterRegistryGroup(settings);
settings.beginGroup(kRegistryConfigGroup);
settings.setValue(key, value);
settings.endGroup();
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot write config value to registry: %1").arg(key);
return false;
}
m_error.clear();
return true;
}
QString ConfigHelper::readFileValue(const QString& key) const
{
if (!isRegistryManagedConfigKey(key))
return QString();
QFile file(m_configPath); QFile file(m_configPath);
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
return QString(); return QString();
@@ -77,59 +785,27 @@ QString ConfigHelper::getValue(const QString& section, const QString& key) const
return document.object().value(key).toVariant().toString(); return document.object().value(key).toVariant().toString();
} }
QString ConfigHelper::getValue(const QString& section, const QString& key) const
{
Q_UNUSED(section);
const QString embeddedValue = readEmbeddedValue(key);
if (!embeddedValue.isEmpty())
return embeddedValue;
if (!isRegistryManagedConfigKey(key))
return QString();
QString value;
if (readRegistryValue(key, &value))
return value;
return readFileValue(key);
}
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value) bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
{ {
Q_UNUSED(section); Q_UNUSED(section);
m_error.clear(); m_error.clear();
QFile input(m_configPath); return writeRegistryValue(key, value);
QJsonObject config;
if (input.exists())
{
if (!input.open(QIODevice::ReadOnly))
{
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
return false;
}
QJsonParseError parseError;
const QByteArray raw = input.readAll();
input.close();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
return false;
}
config = document.object();
}
config.insert(key, value);
QDir dir(QFileInfo(m_configPath).path());
if (!dir.exists() && !dir.mkpath("."))
{
m_error = QString("Cannot create config directory: %1").arg(dir.path());
return false;
}
QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly))
{
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
return false;
}
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
if (output.write(payload) != payload.size())
{
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
output.cancelWriting();
return false;
}
if (!output.commit())
{
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
return false;
}
return true;
} }
bool ConfigHelper::migrateLegacyIniIfNeeded() bool ConfigHelper::migrateLegacyIniIfNeeded()
@@ -161,12 +837,18 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
copyText("Update", "temp_folder", "update_temp"); copyText("Update", "temp_folder", "update_temp");
copyText("Update", "device_id"); copyText("Update", "device_id");
copyText("Runtime", "install_root", "."); copyText("Runtime", "install_root", ".");
copyText("Runtime", "main_executable", "MainApp.exe"); copyText("Runtime", "main_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "MainApp"));
copyText("Runtime", "launcher_executable", "Launcher.exe"); copyText("Runtime", "launcher_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Launcher"));
copyText("Runtime", "updater_executable", "Updater.exe"); copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater"));
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe"); copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap"));
copyText("Runtime", "health_check_timeout_ms", "15000"); copyText("Runtime", "health_check_timeout_ms", "15000");
#ifdef Q_OS_WIN
config.insert("platform", "windows"); config.insert("platform", "windows");
#elif defined(Q_OS_LINUX)
config.insert("platform", "linux");
#else
config.insert("platform", "unknown");
#endif
config.insert("arch", "x64"); config.insert("arch", "x64");
QDir().mkpath(QFileInfo(m_configPath).path()); QDir().mkpath(QFileInfo(m_configPath).path());
+23
View File
@@ -1,15 +1,29 @@
#pragma once #pragma once
#include <QByteArray>
#include <QString> #include <QString>
class QSettings;
class ConfigHelper class ConfigHelper
{ {
public: public:
static ConfigHelper& instance(); static ConfigHelper& instance();
static int runElevatedWriteCommandIfRequested();
static QString executableNameForCurrentPlatform(const QString& configuredValue,
const QString& fallbackBaseName);
static bool writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
QString* errorMessage = nullptr);
static bool removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage = nullptr);
QString getValue(const QString& section, const QString& key) const; QString getValue(const QString& section, const QString& key) const;
bool setValue(const QString& section, const QString& key, const QString& value); bool setValue(const QString& section, const QString& key, const QString& value);
QString configPath() const; QString configPath() const;
QString installRoot() const; QString installRoot() const;
QString runtimeRoot() const; QString runtimeRoot() const;
QString dataRoot() const;
QString dataConfigDir() const;
QString clientIdentityPath() const;
QString policyPath() const;
QString localStatePath() const;
QString updateRoot() const; QString updateRoot() const;
QString runtimeRelativePath() const; QString runtimeRelativePath() const;
QString lastError() const; QString lastError() const;
@@ -17,6 +31,15 @@ public:
private: private:
ConfigHelper(); ConfigHelper();
bool migrateLegacyIniIfNeeded(); 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_configPath;
QString m_registryInstallId;
QString m_error; QString m_error;
}; };
+276 -24
View File
@@ -1,5 +1,7 @@
#include "DeviceIdentityHelper.h" #include "DeviceIdentityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCoreApplication>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
@@ -7,43 +9,293 @@
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo> #include <QSysInfo>
#include <QUuid>
#include <QTimer> #include <QTimer>
#include <QUuid>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;} namespace {
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){ QString manifestPublicKeyPath(const QString &installDir)
{
return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem"));
}
} // namespace
DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir)
: m_installDir(installDir)
{
}
QString DeviceIdentityHelper::deviceId() const
{
return m_deviceId;
}
QString DeviceIdentityHelper::errorString() const
{
return m_error;
}
bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64)
{
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false; Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"OpenSSL is unavailable, so device credential signature cannot be verified.");
return false;
#else #else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;} const QString keyPath = manifestPublicKeyPath(m_installDir);
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;} QFile keyFile(keyPath);
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok; if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) {
BIO_free(bio);
}
if (!publicKey) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath);
return false;
}
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx
&& EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(
ctx,
reinterpret_cast<const unsigned char *>(signature.constData()),
signature.size()) == 1;
if (ctx) {
EVP_MD_CTX_free(ctx);
}
EVP_PKEY_free(publicKey);
if (!ok) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential signature is invalid. The local identity file may not match this server.");
}
return ok;
#endif #endif
} }
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true; bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel)
{
// 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"));
} }
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){ QFile credentialFile(credentialPath);
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;} if (!credentialFile.open(QIODevice::ReadOnly)) {
const QString trimmedBase=base.trimmed(); return false;
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;} }
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;} QJsonParseError parseError;
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;} const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError);
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;} if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1")
.arg(credentialPath);
return false;
}
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8();
const QString signature = wrapper.value(QStringLiteral("signature")).toString();
if (identityText.isEmpty() || !verifySignature(identityText, signature)) {
return false;
}
const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText);
const QJsonObject identity = identityDoc.object();
const QDateTime expiry = QDateTime::fromString(
identity.value(QStringLiteral("valid_until")).toString(),
Qt::ISODate);
const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId
&& identity.value(QStringLiteral("channel")).toString() == expectedChannel
&& !identity.value(QStringLiteral("license_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("installation_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("device_id")).toString().isEmpty();
if (!identityMatches) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential does not match this application, channel, license, installation or device.");
return false;
}
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License has expired. Please ask the administrator to issue a new License.");
return false;
}
m_deviceId = identity.value(QStringLiteral("device_id")).toString();
return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel)
{
m_error.clear();
return loadAndVerify(appId, channel);
}
bool DeviceIdentityHelper::ensureIssued(
const QString &apiBaseUrl,
const QString &clientToken,
const QString &appId,
const QString &channel,
const QString &licenseKey)
{
// 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。
// 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。
m_error.clear();
if (loadAndVerify(appId, channel)) {
ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId);
return true;
}
const QString trimmedBaseUrl = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json.");
return false;
}
if (channel.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json.");
return false;
}
if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000");
return false;
}
if (clientToken.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"client_token is empty. Copy the client_token generated by the admin page into app_config.json.");
return false;
}
if (licenseKey.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License is empty. Create or select a License in the admin page, then copy the generated client configuration.");
return false;
}
ConfigHelper &config = ConfigHelper::instance(); ConfigHelper &config = ConfigHelper::instance();
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}} QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}}; if (installationId.isEmpty()) {
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true; installationId = QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
}
const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8();
const QString machineHash = QString::fromLatin1(
QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex());
const QJsonObject body{
{QStringLiteral("app_id"), appId},
{QStringLiteral("channel"), channel},
{QStringLiteral("license_key"), licenseKey},
{QStringLiteral("installation_id"), installationId},
{QStringLiteral("machine_hash"), machineHash},
};
QNetworkAccessManager manager;
QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
QEventLoop loop;
QTimer timer;
timer.setSingleShot(true);
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance()
.getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms"))
.toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) {
timeoutMs = 5000;
}
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray raw = reply->readAll();
reply->deleteLater();
if (status != 200) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device identity request failed (HTTP %1): %2")
.arg(status)
.arg(QString::fromUtf8(raw));
return false;
}
const QJsonDocument responseDoc = QJsonDocument::fromJson(raw);
const QJsonObject response = responseDoc.object();
if (response.value(QStringLiteral("identity_text")).toString().isEmpty()
|| response.value(QStringLiteral("signature")).toString().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server returned an invalid device identity response.");
return false;
}
const QJsonObject wrapper{
{QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))},
{QStringLiteral("signature"), response.value(QStringLiteral("signature"))},
};
const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
const QString credentialPath = config.clientIdentityPath();
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2")
.arg(credentialPath, writeError);
return false;
}
if (!loadAndVerify(appId, channel)) {
return false;
}
if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
return true;
} }
+16 -3
View File
@@ -1,15 +1,28 @@
#pragma once #pragma once
#include <QByteArray>
#include <QString> #include <QString>
class DeviceIdentityHelper { class DeviceIdentityHelper {
public: public:
explicit DeviceIdentityHelper(const QString &installDir); explicit DeviceIdentityHelper(const QString &installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey); 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); bool verifyLocal(const QString &appId, const QString &channel);
QString deviceId() const; QString deviceId() const;
QString errorString() const; QString errorString() const;
private: private:
bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel); bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel);
bool verifySignature(const QByteArray &payload, const QString &signatureBase64); bool verifySignature(const QByteArray &payload, const QString &signatureBase64);
QString m_installDir, m_deviceId, m_error;
QString m_installDir;
QString m_deviceId;
QString m_error;
}; };
+62 -7
View File
@@ -1,5 +1,20 @@
#include "FileHelper.h" #include "FileHelper.h"
#include <QDebug> #include <QDebug>
#include <QFileInfo>
#include <QThread>
namespace
{
QString processImageName(const QString& exeName)
{
QString name = QFileInfo(exeName).fileName().trimmed();
#ifndef Q_OS_WIN
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
name.chop(4);
#endif
return name;
}
}
bool FileHelper::createDir(const QString &path) bool FileHelper::createDir(const QString &path)
{ {
@@ -15,7 +30,7 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
{ {
if (!QFile::remove(dst)) if (!QFile::remove(dst))
{ {
qDebug() << "无法删除旧文件:" << dst; qDebug() << "Cannot remove old file:" << dst;
return false; return false;
} }
} }
@@ -24,19 +39,59 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
bool FileHelper::isProcessRunning(const QString &exeName) bool FileHelper::isProcessRunning(const QString &exeName)
{ {
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return false;
QProcess process; QProcess process;
process.start("tasklist"); #ifdef Q_OS_WIN
process.start(QStringLiteral("tasklist"), QStringList{
QStringLiteral("/FI"),
QStringLiteral("IMAGENAME eq %1").arg(imageName)
});
process.waitForFinished(); process.waitForFinished();
QString output = process.readAllStandardOutput(); const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
return output.contains(exeName, Qt::CaseInsensitive); 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) bool FileHelper::killProcess(const QString &exeName)
{ {
if (!isProcessRunning(exeName)) if (!isProcessRunning(exeName))
return true; return true;
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return true;
QProcess process; QProcess process;
process.start("taskkill /f /im " + exeName); #ifdef Q_OS_WIN
process.waitForFinished(1000); process.start(QStringLiteral("taskkill"), QStringList{
return !isProcessRunning(exeName); 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;
} }
+4 -1
View File
@@ -17,7 +17,10 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
// Add auth token header // Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token"); QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8()); req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat")); 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)) if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
+1 -1
View File
@@ -11,7 +11,7 @@
class HttpHelper class HttpHelper
{ {
public: public:
// 每次调用独立创建manager,不做成成员变量 // Create an independent manager for each call instead of keeping it as a member.
static void postRequest(const QString& url, const QJsonObject& jsonBody, static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback); std::function<void(int code, const QJsonObject& resp)> callback);
}; };
+8 -2
View File
@@ -28,15 +28,17 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{ {
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{ QSet<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat" "config/client_identity.dat", "config/version_policy.dat"
}; };
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) { if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{ const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat" "config/client_identity.dat", "config/version_policy.dat"
}; };
for (const QString& protectedPath : runtimeProtected) for (const QString& protectedPath : runtimeProtected)
@@ -85,6 +87,8 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
const QString& version) const QString& version)
{ {
m_error.clear(); m_error.clear();
// Manifest cache 来自服务端发布版本时生成的签名清单。
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath( QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
"manifest_cache/manifest_" + version + ".json"); "manifest_cache/manifest_" + version + ".json");
const QString legacyCachePath = QDir(m_installDir).filePath( const QString legacyCachePath = QDir(m_installDir).filePath(
@@ -134,6 +138,8 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
QDir root(m_installDir); QDir root(m_installDir);
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories); QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
// 这能降低被人偷偷塞插件或可执行文件的风险。
while (it.hasNext()) { while (it.hasNext()) {
const QString fullPath = it.next(); const QString fullPath = it.next();
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath)); const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
+18 -17
View File
@@ -1,28 +1,27 @@
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
#include <QFile> #include "ConfigHelper.h"
#include <QFileInfo>
#include <QJsonDocument>
#include <QDir> #include <QDir>
#include <QFile>
#include <QJsonDocument>
LocalStateHelper::LocalStateHelper(const QString& baseDir) LocalStateHelper::LocalStateHelper(const QString& baseDir)
: m_baseDir(baseDir) : m_baseDir(baseDir)
, m_filePath(baseDir + "/config/local_state.json") , m_filePath(ConfigHelper::instance().localStatePath())
{ {
} }
bool LocalStateHelper::loadState(const QString& relativePath) bool LocalStateHelper::loadState(const QString& relativePath)
{ {
const QString defaultState = QStringLiteral("config/local_state.json");
if (relativePath == defaultState)
m_filePath = ConfigHelper::instance().localStatePath();
else if (QDir::isAbsolutePath(relativePath))
m_filePath = relativePath;
else
m_filePath = m_baseDir + "/" + relativePath; m_filePath = m_baseDir + "/" + relativePath;
QFile file(m_filePath); QFile file(m_filePath);
if (!file.exists()) if (!file.exists())
{ {
QDir dir(QFileInfo(m_filePath).path());
if (!dir.exists() && !dir.mkpath("."))
{
m_error = QString("Cannot create state directory: %1").arg(dir.path());
return false;
}
m_state = QJsonObject{ m_state = QJsonObject{
{"max_policy_seq", 0}, {"max_policy_seq", 0},
{"last_success_run_at", QString()}, {"last_success_run_at", QString()},
@@ -63,17 +62,19 @@ bool LocalStateHelper::loadState(const QString& relativePath)
bool LocalStateHelper::saveState() const bool LocalStateHelper::saveState() const
{ {
if (!m_loaded) if (!m_loaded)
return false;
QFile file(m_filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
{ {
m_error = "State is not loaded";
return false; return false;
} }
QJsonDocument doc(m_state); QJsonDocument doc(m_state);
file.write(doc.toJson(QJsonDocument::Indented)); QString writeError;
file.close(); if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
{
m_error = QString("Cannot save state file: %1").arg(writeError);
return false;
}
m_error.clear();
return true; return true;
} }
+1 -1
View File
@@ -27,7 +27,7 @@ public:
private: private:
QString m_baseDir; QString m_baseDir;
QString m_filePath; QString m_filePath;
QString m_error; mutable QString m_error;
QJsonObject m_state; QJsonObject m_state;
bool m_loaded = false; bool m_loaded = false;
}; };
+24 -5
View File
@@ -1,10 +1,11 @@
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QDateTime> #include <QDateTime>
#include <QDir>
#include <QFile> #include <QFile>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile>
#include <algorithm> #include <algorithm>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
@@ -13,9 +14,22 @@
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {} PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
static QString resolvePolicyPath(const QString& baseDir, const QString& relativePath, bool forWrite)
{
const QString defaultPolicy = QStringLiteral("config/version_policy.dat");
if (relativePath == defaultPolicy) {
const QString runtimePolicy = ConfigHelper::instance().policyPath();
if (forWrite || QFile::exists(runtimePolicy))
return runtimePolicy;
}
if (QDir::isAbsolutePath(relativePath))
return relativePath;
return baseDir + "/" + relativePath;
}
bool PolicyHelper::loadPolicy(const QString& relativePath) bool PolicyHelper::loadPolicy(const QString& relativePath)
{ {
QFile file(m_baseDir + "/" + relativePath); QFile file(resolvePolicyPath(m_baseDir, relativePath, false));
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; } if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
QJsonParseError error; QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
@@ -33,10 +47,15 @@ bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& si
bool PolicyHelper::savePolicy(const QString& relativePath) const bool PolicyHelper::savePolicy(const QString& relativePath) const
{ {
QSaveFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::WriteOnly)) return false;
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented); const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
return file.write(bytes) == bytes.size() && file.commit(); QString writeError;
const QString path = resolvePolicyPath(m_baseDir, relativePath, true);
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) {
m_error = "Cannot save policy file: " + writeError;
return false;
}
m_error.clear();
return true;
} }
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
+27 -7
View File
@@ -20,6 +20,8 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret, const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage) QString* ticketPath, QString* errorMessage)
{ {
// Launcher 启动业务主程序前生成一次性 ticket。
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) { if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "ticket identity or secret is empty"; if (errorMessage) *errorMessage = "ticket identity or secret is empty";
return false; return false;
@@ -51,6 +53,8 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const QString& expectedDeviceId, const QString& expectedVersion, const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage) const QString& secret, QString* errorMessage)
{ {
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
const QString consumingPath = ticketPath + ".consuming." const QString consumingPath = ticketPath + ".consuming."
+ QString::number(QCoreApplication::applicationPid()); + QString::number(QCoreApplication::applicationPid());
if (!QFile::rename(ticketPath, consumingPath)) { if (!QFile::rename(ticketPath, consumingPath)) {
@@ -64,7 +68,7 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
return false; return false;
} }
const QByteArray raw = file.readAll(); file.close(); const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。 QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure.
QJsonParseError parseError; QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
@@ -77,14 +81,30 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate); const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate); const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
const bool identityOk = payload.value("app_id").toString() == expectedAppId
&& payload.value("device_id").toString() == expectedDeviceId
&& payload.value("version").toString() == expectedVersion;
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5) const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
&& expires >= now && issued.secsTo(expires) <= 65; && expires >= now && issued.secsTo(expires) <= 65;
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk if (actual.isEmpty() || actual != expected) {
|| payload.value("nonce").toString().isEmpty()) { if (errorMessage) *errorMessage = "ticket signature invalid; check launch_token";
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid"; return false;
}
if (payload.value("app_id").toString() != expectedAppId) {
if (errorMessage) *errorMessage = "ticket app_id mismatch";
return false;
}
if (payload.value("device_id").toString() != expectedDeviceId) {
if (errorMessage) *errorMessage = "ticket device_id mismatch";
return false;
}
if (payload.value("version").toString() != expectedVersion) {
if (errorMessage) *errorMessage = "ticket version mismatch";
return false;
}
if (!timeOk) {
if (errorMessage) *errorMessage = "ticket time invalid or expired";
return false;
}
if (payload.value("nonce").toString().isEmpty()) {
if (errorMessage) *errorMessage = "ticket nonce missing";
return false; return false;
} }
return true; return true;
@@ -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。
+3 -7
View File
@@ -8,14 +8,14 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
# 依赖Qt模块:网络、基础核心、窗口
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
# 所有源码文件 # 所有源码文件
set(SRC_LIST set(SRC_LIST
main.cpp main.cpp
UpdateLogic.h UpdateLogic.h
UpdateLogic.cpp UpdateLogic.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
) )
# 生成可执行程序 # 生成可执行程序
add_executable(Launcher ${SRC_LIST}) add_executable(Launcher ${SRC_LIST})
@@ -28,10 +28,6 @@ target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
# 链接Common,自动带上OpenSSL库 # 链接Common,自动带上OpenSSL库
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets) target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
# 输出编译产物到 out 文件夹(干净不乱)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
# 强制VS打开CMake文件夹时默认选中该可执行目标 # 强制VS打开CMake文件夹时默认选中该可执行目标
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# 编译完成自动执行windeployqt,复制Qt dll到exe目录 # 编译完成自动执行windeployqt,复制Qt dll到exe目录
+62 -1
View File
@@ -1,8 +1,10 @@
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QDebug> #include <QDebug>
#include <QJsonArray> #include <QJsonArray>
#include <QApplication> #include <QApplication>
#include <QDir>
#include <QSaveFile>
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
@@ -99,6 +101,65 @@ void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, c
}); });
} }
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir)
{
m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
m_error = "manifest identity is incomplete";
return false;
}
QJsonObject response;
int statusCode = 0;
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = version;
body["version_id"] = versionId;
m_http.postRequest(m_serverAddr + "/api/v1/update/manifest", body,
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
});
if (statusCode != 200) {
m_error = QString("manifest request failed (HTTP %1)").arg(statusCode);
return false;
}
const QJsonObject manifest = response.value("manifest").toObject();
const QString manifestText = response.value("manifest_text").toString();
if (manifest.isEmpty() || manifestText.isEmpty()) {
m_error = "manifest response is incomplete";
return false;
}
if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = "manifest identity does not match current version";
return false;
}
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) {
m_error = "cannot create manifest cache directory";
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 = "cannot save signed manifest cache";
return false;
}
qDebug() << "Current version manifest cached to" << file.fileName();
return true;
}
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
{ {
QString url = m_serverAddr + "/api/v1/update/report"; QString url = m_serverAddr + "/api/v1/update/report";
+4
View File
@@ -13,12 +13,15 @@ public:
void checkUpdate(); void checkUpdate();
void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId); void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId);
void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
bool cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir);
bool getNeedUpdate() const { return m_needUpdate; } bool getNeedUpdate() const { return m_needUpdate; }
QString getLatestVersion() const { return m_latestVer; } QString getLatestVersion() const { return m_latestVer; }
QJsonObject getCheckResult() const { return m_checkResp; } QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; } bool isNetworkOk() const { return m_networkOk; }
int lastStatusCode() const { return m_lastStatusCode; } int lastStatusCode() const { return m_lastStatusCode; }
QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; } QString getAppId() const { return m_appId; }
QString getChannel() const { return m_channel; } QString getChannel() const { return m_channel; }
@@ -35,4 +38,5 @@ private:
int m_lastStatusCode = 0; int m_lastStatusCode = 0;
QString m_latestVer; QString m_latestVer;
QJsonObject m_checkResp; QJsonObject m_checkResp;
QString m_error;
}; };
+125 -53
View File
@@ -1,12 +1,12 @@
#include <windows.h>
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QInputDialog> #include <QInputDialog>
#include <QLineEdit> #include <QLineEdit>
#include <QTextCodec> #include <QTranslator>
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
@@ -19,13 +19,18 @@
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(65001);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher"); QApplication::setApplicationName("Marsco Launcher");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
QProgressDialog progress("正在检查软件更新...", QString(), 0, 0); const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
progress.setWindowTitle("Marsco 软件启动器"); 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.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
@@ -38,13 +43,13 @@ int main(int argc, char* argv[])
const auto isLicenseError = [](const QString& errorText) { const auto isLicenseError = [](const QString& errorText) {
const QString text = errorText.toLower(); const QString text = errorText.toLower();
return text.contains("license") return text.contains("license")
|| errorText.contains("授权") || text.contains("authorization")
|| errorText.contains("过期") || text.contains("expired")
|| errorText.contains("设备数量已达到上限") || text.contains("device limit")
|| errorText.contains("已绑定其他授权"); || text.contains("bound to another license");
}; };
const auto clearDeviceCredential = [&]() { const auto clearDeviceCredential = [&]() {
QFile::remove(QDir(appDir).filePath("config/client_identity.dat")); ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath());
config.setValue("Update", "device_id", QString()); config.setValue("Update", "device_id", QString());
}; };
QString licenseKey = config.getValue("License", "license_key").trimmed(); QString licenseKey = config.getValue("License", "license_key").trimmed();
@@ -55,38 +60,47 @@ int main(int argc, char* argv[])
bool accepted = false; bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed(); const QString appName = config.getValue("App", "app_name").trimmed();
const QString prompt = promptReason.trimmed().isEmpty() const QString prompt = promptReason.trimmed().isEmpty()
? QString("请输入%1授权 License").arg(appName.isEmpty() ? "软件" : appName) ? QCoreApplication::translate("Launcher", "Please enter the License for %1:")
: QString("%1\n\n请重新输入%2授权 License").arg(promptReason, appName.isEmpty() ? "软件" : appName); .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( licenseKey = QInputDialog::getText(
nullptr, nullptr,
"输入 License", QCoreApplication::translate("Launcher", "Enter License"),
prompt, prompt,
QLineEdit::Normal, QLineEdit::Normal,
QString(), QString(),
&accepted &accepted
).trimmed(); ).trimmed();
if (!accepted) { if (!accepted) {
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。"); QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Required"),
QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch."));
return QString(); return QString();
} }
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty()) {
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。"); QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
QCoreApplication::translate("Launcher", "Please paste the License created in the admin page."));
promptReason.clear(); promptReason.clear();
continue; continue;
} }
if (!config.setValue("License", "license_key", licenseKey)) { if (!config.setValue("License", "license_key", licenseKey)) {
QMessageBox::critical(nullptr, "保存 License 失败", QMessageBox::critical(nullptr,
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError())); QCoreApplication::translate("Launcher", "Failed to Save License"),
QCoreApplication::translate("Launcher", "Cannot write the configuration file:\n%1\n%2").arg(config.configPath(), config.lastError()));
return QString(); return QString();
} }
progress.show(); progress.show();
progress.setLabelText("正在验证 License..."); progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License..."));
QApplication::processEvents(); QApplication::processEvents();
return licenseKey; return licenseKey;
} }
}; };
while (true) { while (true) {
// License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。
// 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty()) {
licenseKey = promptAndSaveLicense(QString()); licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty()) return 0;
@@ -102,11 +116,13 @@ int main(int argc, char* argv[])
const QString error = identity.errorString(); const QString error = identity.errorString();
if (!isLicenseError(error)) { if (!isLicenseError(error)) {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "设备身份验证失败", error); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Device Authentication Failed"),
error);
return -1; return -1;
} }
clearDeviceCredential(); clearDeviceCredential();
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error)); licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error));
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty()) return 0;
} }
@@ -120,47 +136,61 @@ int main(int argc, char* argv[])
const int targetVersionId = response.value("version_id").toInt(); const int targetVersionId = response.value("version_id").toInt();
const QString appId = logic.getAppId(); const QString appId = logic.getAppId();
const QString channel = logic.getChannel(); const QString channel = logic.getChannel();
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close(); progress.close();
const QJsonValue detail = response.value("detail"); const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message; const QString displayMessage = message.isEmpty()
? QCoreApplication::translate("Launcher", "The device or License authorization is invalid.")
: message;
if (isLicenseError(displayMessage)) { if (isLicenseError(displayMessage)) {
clearDeviceCredential(); clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage)); const QString newLicense = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current authorization was rejected by the server: %1").arg(displayMessage));
if (!newLicense.isEmpty()) { if (!newLicense.isEmpty()) {
progress.close(); progress.close();
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。"); QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Saved"),
QCoreApplication::translate("Launcher", "Please restart Launcher to complete device authorization and update checking."));
} }
return 0; return 0;
} }
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Authorization Rejected"),
displayMessage);
return -1; return -1;
} }
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
const auto configuredName = [&](const QString& key, const QString& fallback) { const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed(); return ConfigHelper::executableNameForCurrentPlatform(
return value.isEmpty() ? fallback : value; config.getValue("Runtime", key), fallback);
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString mainAppPath = QDir(appDir).filePath(mainExecutable); const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable); const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
const auto importOfflinePackage = [&]() { const auto importOfflinePackage = [&]() {
const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)"); 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)}); return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
}; };
const QString deviceId = config.getValue("Update", "device_id"); const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) { if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close(); progress.close();
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。"); if (!importOfflinePackage())
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected."));
return 0; return 0;
} }
const auto startMainApp = [&]() { const auto startMainApp = [&]() {
// 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。
// 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
@@ -174,27 +204,32 @@ int main(int argc, char* argv[])
return started; return started;
}; };
progress.setLabelText("正在验证本地运行策略..."); progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy..."));
QApplication::processEvents(); QApplication::processEvents();
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString())); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
return -1; return -1;
} }
if (policy.isExpired()) if (policy.isExpired())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。"); QMessageBox::critical(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; return -1;
} }
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "当前版本不可运行", QMessageBox::critical(nullptr,
policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion) QCoreApplication::translate("Launcher", "Current Version Cannot Run"),
policy.message().isEmpty() ? QCoreApplication::translate("Launcher", "The current version %1 has been disabled by the administrator.").arg(currentVersion)
: policy.message()); : policy.message());
return -1; return -1;
} }
@@ -203,19 +238,25 @@ int main(int argc, char* argv[])
if (!state.loadState()) if (!state.loadState())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString())); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq())) if (state.isPolicySeqRolledBack(policy.policySeq()))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected. Startup has been blocked."));
return -1; return -1;
} }
if (state.isSystemTimeRewound()) if (state.isSystemTimeRewound())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A possible system time rollback was detected. Startup has been blocked."));
return -1; return -1;
} }
@@ -223,12 +264,13 @@ int main(int argc, char* argv[])
{ {
progress.close(); progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
const QString dialogTitle = rollbackOperation ? "版本回退" const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback")
: (policy.forceUpdate() ? "必须更新" : "发现新版本"); : (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available"));
const QString prompt = policy.message().isEmpty() const QString prompt = policy.message().isEmpty()
? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?" ? (rollbackOperation
: "发现新版本 %1,是否现在更新?").arg(latestVer) ? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer)
: policy.message() + QString("\n目标版本:%1").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; bool accepted = true;
if (policy.forceUpdate()) { if (policy.forceUpdate()) {
QMessageBox::information(nullptr, dialogTitle, prompt); QMessageBox::information(nullptr, dialogTitle, prompt);
@@ -238,7 +280,9 @@ int main(int argc, char* argv[])
} }
if (!accepted) { if (!accepted) {
if (!startMainApp()) { if (!startMainApp()) {
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
return -1; return -1;
} }
return 0; return 0;
@@ -246,17 +290,39 @@ int main(int argc, char* argv[])
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
if (!QProcess::startDetached(updaterPath, updaterArgs)) if (!QProcess::startDetached(updaterPath, updaterArgs))
{ {
QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath)); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath));
return -1; return -1;
} }
return 0; return 0;
} }
if (networkOk && !needUpdate && targetVersionId > 0 && latestVer == currentVersion)
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Caching signed version manifest..."));
QApplication::processEvents();
const QString manifestCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("manifest_cache");
if (!logic.cacheManifest(appId, channel, currentVersion, targetVersionId, manifestCacheDir))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Manifest Cache Failed"),
QCoreApplication::translate("Launcher", "Cannot cache the signed manifest for the current version: %1").arg(logic.errorString()));
return -1;
}
}
if (!networkOk) { if (!networkOk) {
progress.close(); progress.close();
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", if (QMessageBox::question(nullptr,
QCoreApplication::translate("Launcher", "Server Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server. Import an offline update package?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。"); if (!importOfflinePackage())
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started."));
return 0; return 0;
} }
progress.show(); progress.show();
@@ -265,16 +331,22 @@ int main(int argc, char* argv[])
if (!networkOk && !policy.isOfflineAllowed()) if (!networkOk && !policy.isOfflineAllowed())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Network Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server, and the current policy does not allow offline startup."));
return -1; return -1;
} }
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); progress.setLabelText(networkOk
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
QApplication::processEvents(); QApplication::processEvents();
if (!startMainApp()) if (!startMainApp())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
return -1; return -1;
} }
progress.close(); progress.close();
+2
View File
@@ -4,6 +4,8 @@ add_executable(MainApp
MainWindow.h MainWindow.h
MainWindow.cpp MainWindow.cpp
../Common/ConfigHelper.h ../Common/ConfigHelper.h
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
) )
target_include_directories(MainApp target_include_directories(MainApp
PUBLIC ${CMAKE_SOURCE_DIR}/Common PUBLIC ${CMAKE_SOURCE_DIR}/Common
+10 -7
View File
@@ -1,11 +1,10 @@
#include <Windows.h> #include <QApplication>
#include <QApplication>
#include <QDebug> #include <QDebug>
#include <QTextCodec>
#include <QMessageBox> #include <QMessageBox>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QSaveFile> #include <QSaveFile>
#include <QTranslator>
#include "MainWindow.h" #include "MainWindow.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
@@ -16,16 +15,20 @@
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(936);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK"));
QApplication a(argc, 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; qDebug() << Qt::endl << "entered main app" << Qt::endl;
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher");
QString ticketFilePath; QString ticketFilePath;
QString healthFilePath; QString healthFilePath;
for (int i = 1; i < argc; ++i) for (int i = 1; i < argc; ++i)
-27
View File
@@ -1,27 +0,0 @@
客户端配置说明
==============
统一从程序目录下的 config/app_config.json 读取配置。
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
接入新软件时通常需要修改:
1. app_id、app_name、channel、current_version。
2. api_base_url、client_token、license_key、launch_token。
3. main_executable:团队业务主程序文件名。
4. launcher_executable、updater_executable、bootstrap_executable。
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
完整格式参考 config/app_config.example.json。
运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。
Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
4. 在 PowerShell 执行:
powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
-188
View File
@@ -1,188 +0,0 @@
# UpdateClientSDK 接入说明
这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。
SDK 核心程序:
- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。
- `config/app_config.json`:接入方配置。
- `config/manifest_public_key.pem`:验证服务端签名用的公钥。
## 接入方需要做什么
假设接入的软件叫 `YourApp.exe`
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`
2. 创建或确认渠道,例如 `stable`
3. 创建 License,把生成的 `license_key` 填到客户端配置。
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`
5. 把 SDK 的 `Launcher.exe``Updater.exe``Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。
6.`config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
## 安装目录写权限要求
当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如:
```text
config/app_config.json
config/client_identity.dat
config/local_state.json
config/version_policy.dat
update/
update_temp/
```
所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`
推荐联调目录:
```text
D:\SimCAE_Release\
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
```
如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。
## SimCAE 目录结构建议
SimCAE 当前安装目录是根目录下有 `bin/``Licenses/``installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录:
```text
SimCAE/
bin/
Launcher.exe
Updater.exe
Bootstrap.exe
SimCAE.exe
config/
app_config.json
manifest_public_key.pem
update/
manifest_cache/
Qt5Core.dll
...
Licenses/
installerResources/
maintenancetool.exe
```
这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root``..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll``platforms/``imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。
## app_config.json 关键字段
```json
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
```
字段说明:
- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。
- `channel`:发布渠道,例如 `stable``beta``dev`
- `current_version`:当前客户端初始版本。
- `client_protocol`:客户端协议号,当前建议为 `3`
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
## 业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了:
- 启动票据校验。
- 本地 License/设备身份校验。
- 本地策略校验。
- Manifest 完整性校验。
- 健康标记写入。
如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。
## 如何生成 SDK 包
在 Windows PowerShell 中执行:
```powershell
cd client
.\package-sdk.ps1 `
-SourceDir .\out\bin `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`
生成结果:
```text
dist/UpdateClientSDK/
SimCAE自动升级SDK接入说明_v0.1.docx
sdk_manifest.json
bin/
config/
app_config.json
manifest_public_key.pem
scripts/
```
`UpdateClientSDK.zip` 发给接入方即可。
## 如何生成某个产品的最终客户端包
SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用:
```powershell
cd client
.\package-client.ps1 `
-SourceDir .\out\bin `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。
## 常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
3. 首次启动设备登记失败:检查 `api_base_url``client_token``license_key`、服务端 License 状态。
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`
+4 -3
View File
@@ -1,11 +1,10 @@
if(WIN32 AND MSVC)
# 强制所有编译、设计时生成均使用x64,禁止Win32 # 强制所有编译、设计时生成均使用x64,禁止Win32
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32") set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64) set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
# 关闭VS自动Win32设计时预生成 # 关闭VS自动Win32设计时预生成
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF) set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
# 清除32位Strawberry Perl路径干扰 endif()
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include")
list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib")
project(Updater) project(Updater)
set(SRC set(SRC
@@ -14,6 +13,8 @@ set(SRC
UpdaterLogic.cpp UpdaterLogic.cpp
UpdateTransaction.h UpdateTransaction.h
UpdateTransaction.cpp UpdateTransaction.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
${CMAKE_SOURCE_DIR}/config/server_config.qrc
) )
add_executable(Updater ${SRC}) add_executable(Updater ${SRC})
+7
View File
@@ -37,6 +37,8 @@ bool UpdateTransaction::copyOverwrite(const QString& source, const QString& dest
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message) bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
{ {
// upgrade_state.json 是升级事务的“黑匣子”。
// 如果替换文件时断电或崩溃,Bootstrap/Updater 会根据这里的状态继续提交或回滚。
m_state["transaction_id"] = m_transactionId; m_state["transaction_id"] = m_transactionId;
m_state["from_version"] = m_fromVersion; m_state["from_version"] = m_fromVersion;
m_state["to_version"] = m_toVersion; m_state["to_version"] = m_toVersion;
@@ -148,6 +150,8 @@ bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
bool UpdateTransaction::backupCurrentFiles() bool UpdateTransaction::backupCurrentFiles()
{ {
// 替换前先备份所有将被修改或删除的文件。
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
if (!writeState("waiting_mainapp_exit")) return false; if (!writeState("waiting_mainapp_exit")) return false;
QStringList paths = m_changedPaths; QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths); paths.append(m_obsoletePaths);
@@ -163,6 +167,8 @@ bool UpdateTransaction::backupCurrentFiles()
bool UpdateTransaction::installStagedFiles(QString* failedPath) bool UpdateTransaction::installStagedFiles(QString* failedPath)
{ {
// staging 目录里只放已经下载并校验过 hash 的新文件。
// 真正覆盖安装目录时如果任意一个文件失败,就进入 rollback_required。
if (!writeState("replacing")) return false; if (!writeState("replacing")) return false;
for (const QString& path : m_changedPaths) { for (const QString& path : m_changedPaths) {
const QString source = QDir(m_stagingDir).filePath(path); const QString source = QDir(m_stagingDir).filePath(path);
@@ -226,6 +232,7 @@ QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePat
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir, bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion, QString* errorMessage) QString* restoredVersion, QString* errorMessage)
{ {
// 启动时恢复未完成事务:如果上次升级停在替换/验证/回滚中间,优先恢复到可启动状态。
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir) QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
.filePath("upgrade_state.json"); .filePath("upgrade_state.json");
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json"); const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
+33 -20
View File
@@ -3,6 +3,7 @@
#include <QDebug> #include <QDebug>
#include <QFileInfo> #include <QFileInfo>
#include <QFile> #include <QFile>
#include <QCoreApplication>
#include <QEventLoop> #include <QEventLoop>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QThread> #include <QThread>
@@ -32,6 +33,8 @@ UpdaterLogic::UpdaterLogic(QObject* parent)
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId) void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{ {
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
QString url = m_serverAddr + "/api/v1/update/manifest"; QString url = m_serverAddr + "/api/v1/update/manifest";
QJsonObject body; QJsonObject body;
body["app_id"] = appId; body["app_id"] = appId;
@@ -146,6 +149,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
{ {
// 验签用的是客户端随 SDK 分发的公钥。
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
if (m_manifest.isEmpty() || m_manifestText.isEmpty()) if (m_manifest.isEmpty() || m_manifestText.isEmpty())
{ {
qDebug() << "No manifest available to verify"; qDebug() << "No manifest available to verify";
@@ -168,6 +173,8 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
{ {
// 启动后的完整性检查依赖本地 Manifest 缓存。
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
if (m_manifest.isEmpty()) if (m_manifest.isEmpty())
return false; return false;
@@ -264,8 +271,11 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
} }
QSet<QString> protectedPaths{ QSet<QString> protectedPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"), QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"),
QStringLiteral("launcher.exe"), QStringLiteral("launcher.exe"),
QStringLiteral("updater"),
QStringLiteral("updater.exe"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/app_config.json"),
@@ -276,7 +286,9 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) { if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{ const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"), QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"), QStringLiteral("launcher.exe"),
QStringLiteral("updater"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"), QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"), QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat") QStringLiteral("config/version_policy.dat")
@@ -341,45 +353,45 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
{ {
m_offlineError.clear(); m_offlineError.clear();
QFile package(packagePath); QFile package(packagePath);
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; } if (!package.open(QIODevice::ReadOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot open the offline update package"); return false; }
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; } if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package format identifier is invalid"); return false; }
const QByteArray lengthBytes = package.read(8); const QByteArray lengthBytes = package.read(8);
if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; } if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete"); return false; }
quint64 headerSize = 0; quint64 headerSize = 0;
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8); for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; } if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header length is invalid"); return false; }
QJsonParseError error; QJsonParseError error;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error); const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; } if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header JSON is invalid"); return false; }
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDoc.object();
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8(); const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem"; const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem";
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; } if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package RSA signature is invalid"); return false; }
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object(); const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; } if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package signature metadata is invalid"); return false; }
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; } if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The manifest digest does not match the package signature"); return false; }
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object(); QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; } if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; }
manifest.insert("signature", wrapper.value("manifest_signature").toString()); manifest.insert("signature", wrapper.value("manifest_signature").toString());
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; } if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; }
if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; } if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid"); return false; }
const qint64 payloadStart = 16 + qint64(headerSize); const qint64 payloadStart = 16 + qint64(headerSize);
const QJsonArray entries = packageMeta.value("files").toArray(); const QJsonArray entries = packageMeta.value("files").toArray();
for (const QJsonValue& value : entries) { for (const QJsonValue& value : entries) {
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; } if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-range data: %1").arg(path); return false; }
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi); FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
if (stagingDir.isEmpty()) continue; if (stagingDir.isEmpty()) continue;
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; } const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare offline file: %1").arg(path); return false; }
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; } QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create staged file: %1").arg(path); return false; }
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size; QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); } while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Failed to read offline file: %1").arg(path); return false; } hash.addData(block); remaining -= block.size(); }
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; } if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Offline file hash verification or write failed: %1").arg(path); return false; }
} }
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; } if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package file count does not match the manifest"); return false; }
return true; return true;
} }
@@ -514,7 +526,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (existingSize > 0 && httpStatus == 200) if (existingSize > 0 && httpStatus == 200)
{ {
// 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。 // The server ignored Range; the current file is "old fragment + full response" and must be redownloaded safely.
qDebug() << "Server ignored Range; restart full download:" << savePath; qDebug() << "Server ignored Range; restart full download:" << savePath;
QFile::remove(partPath); QFile::remove(partPath);
} }
@@ -562,6 +574,7 @@ bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{ {
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{ QSet<QString> protectedPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"), QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/app_config.json"),
@@ -572,7 +585,7 @@ bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) { if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{ const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"), QStringLiteral("bootstrap"), QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"), QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat") QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
}; };
+152 -72
View File
@@ -1,5 +1,4 @@
#include <windows.h> #include <QApplication>
#include <QApplication>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
@@ -11,8 +10,8 @@
#include <QProgressDialog> #include <QProgressDialog>
#include <QSaveFile> #include <QSaveFile>
#include <QStorageInfo> #include <QStorageInfo>
#include <QTextCodec>
#include <QThread> #include <QThread>
#include <QTranslator>
#include "UpdaterLogic.h" #include "UpdaterLogic.h"
#include "UpdateTransaction.h" #include "UpdateTransaction.h"
#include "FileHelper.h" #include "FileHelper.h"
@@ -23,10 +22,15 @@
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(65001);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater"); QApplication::setApplicationName("Marsco Updater");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
UpdaterLogic logic; UpdaterLogic logic;
QString offlinePackagePath; QString offlinePackagePath;
@@ -37,7 +41,9 @@ int main(int argc, char* argv[])
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
if (!logic.loadOfflinePackage(offlinePackagePath)) { if (!logic.loadOfflinePackage(offlinePackagePath)) {
QMessageBox::critical(nullptr, "离线包无效", logic.offlineError()); QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Invalid Offline Package"),
logic.offlineError());
return -1; return -1;
} }
const QJsonObject packageManifest = logic.getManifest(); const QJsonObject packageManifest = logic.getManifest();
@@ -47,7 +53,9 @@ int main(int argc, char* argv[])
targetVersionId = packageManifest.value("manifest_seq").toInt(); targetVersionId = packageManifest.value("manifest_seq").toInt();
} else { } else {
if (argc < 5) { if (argc < 5) {
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Updater Argument Error"),
QCoreApplication::translate("Updater", "The updater is missing online update arguments or an offline update package."));
return -1; return -1;
} }
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt(); appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
@@ -66,7 +74,9 @@ int main(int argc, char* argv[])
const QString updateDir = config.updateRoot(); const QString updateDir = config.updateRoot();
QDir().mkpath(updateDir); QDir().mkpath(updateDir);
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) { if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
return -1; return -1;
} }
QString fromVersion = config.getValue("App", "current_version"); QString fromVersion = config.getValue("App", "current_version");
@@ -74,13 +84,17 @@ int main(int argc, char* argv[])
PolicyHelper offlinePolicy(runtimeDir); PolicyHelper offlinePolicy(runtimeDir);
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired() if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) { || !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。"); 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; return -1;
} }
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion), if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
QVersionNumber::fromString(fromVersion)) < 0 QVersionNumber::fromString(fromVersion)) < 0
&& !offlinePolicy.allowRollback()) { && !offlinePolicy.allowRollback()) {
QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。"); 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; return -1;
} }
} }
@@ -91,21 +105,25 @@ int main(int argc, char* argv[])
QString restoredVersion; QString restoredVersion;
QString recoveryError; QString recoveryError;
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) { if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
QMessageBox::critical(nullptr, "更新恢复失败", QMessageBox::critical(nullptr,
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError)); QCoreApplication::translate("Updater", "Update Recovery Failed"),
QCoreApplication::translate("Updater", "An unfinished update was detected, but the old version could not be restored: %1\nDo not continue running the software. Please contact the administrator.")
.arg(recoveryError));
return -1; return -1;
} }
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) { if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
if (!config.setValue("App", "current_version", restoredVersion)) { if (!config.setValue("App", "current_version", restoredVersion)) {
QMessageBox::critical(nullptr, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Update Recovery Failed"),
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory."));
return -1; return -1;
} }
fromVersion = restoredVersion; fromVersion = restoredVersion;
} }
} }
QProgressDialog progress("正在准备更新...", QString(), 0, 100); QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing update..."), QString(), 0, 100);
progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion)); progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
progress.setCancelButton(nullptr); progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
@@ -133,8 +151,9 @@ int main(int argc, char* argv[])
QObject::connect(&logic, &UpdaterLogic::downloadProgress, QObject::connect(&logic, &UpdaterLogic::downloadProgress,
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) { [&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60; const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
const QString fileText = path.isEmpty() ? QString("正在准备下载...") const QString fileText = path.isEmpty()
: QString("正在下载:%1").arg(path); ? QCoreApplication::translate("Updater", "Preparing download...")
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
progress.setValue(value); progress.setValue(value);
progress.setLabelText(QString("%1\n%2 / %3 · %4/s") progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
.arg(fileText, formatBytes(received), formatBytes(total), .arg(fileText, formatBytes(received), formatBytes(total),
@@ -154,12 +173,12 @@ int main(int argc, char* argv[])
return -1; return -1;
}; };
const auto configuredName = [&](const QString& key, const QString& fallback) { const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed(); return ConfigHelper::executableNameForCurrentPlatform(
return value.isEmpty() ? fallback : value; config.getValue("Runtime", key), fallback);
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe"); const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
bool timeoutOk = false; bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
@@ -196,7 +215,7 @@ int main(int argc, char* argv[])
const auto delegateRollback = [&](const QString& title, const QString& reason) { const auto delegateRollback = [&](const QString& title, const QString& reason) {
FileHelper::killProcess(mainExecutable); FileHelper::killProcess(mainExecutable);
transaction.markRollbackRequired(reason); transaction.markRollbackRequired(reason);
progress.setLabelText("正在将回滚工作移交给 Bootstrap..."); progress.setLabelText(QCoreApplication::translate("Updater", "Delegating rollback to Bootstrap..."));
QApplication::processEvents(); QApplication::processEvents();
if (launchBootstrap("rollback")) { if (launchBootstrap("rollback")) {
progress.close(); progress.close();
@@ -205,7 +224,7 @@ int main(int argc, char* argv[])
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, title, QMessageBox::critical(nullptr, title,
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。"); reason + QCoreApplication::translate("Updater", "\n\nCannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator."));
return -1; return -1;
}; };
@@ -214,8 +233,9 @@ int main(int argc, char* argv[])
if (!transaction.resumeExisting(&resumeError)) { if (!transaction.resumeExisting(&resumeError)) {
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, "事务续办失败", QMessageBox::critical(nullptr,
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError)); QCoreApplication::translate("Updater", "Transaction Resume Failed"),
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
return -1; return -1;
} }
fromVersion = transaction.fromVersion(); fromVersion = transaction.fromVersion();
@@ -227,38 +247,52 @@ int main(int argc, char* argv[])
reportUpdateResult(false); reportUpdateResult(false);
if (stateOk) launchMainApp(); if (stateOk) launchMainApp();
progress.close(); progress.close();
QMessageBox::warning(nullptr, "更新已回滚", QMessageBox::warning(nullptr,
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。" QCoreApplication::translate("Updater", "Update Rolled Back"),
: "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。"); stateOk
? QCoreApplication::translate("Updater", "The new version failed to install or start. The old version has been restored and started automatically.")
: QCoreApplication::translate("Updater", "The old files were restored, but the old version number could not be written back. Please check configuration directory permissions."));
return stateOk ? 0 : -1; return stateOk ? 0 : -1;
} }
if (bootstrapResult != "success") { if (bootstrapResult != "success") {
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, "自动回滚失败", QMessageBox::critical(nullptr,
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。"); QCoreApplication::translate("Updater", "Automatic Rollback Failed"),
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator."));
return -1; return -1;
} }
} else if (!transaction.initialize()) { } else if (!transaction.initialize()) {
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed"); return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save the transaction state."),
"transaction_init_failed");
} else if (!offlinePackagePath.isEmpty()) { } else if (!offlinePackagePath.isEmpty()) {
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode")); QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8) if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed"); return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"),
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
"offline_marker_failed");
} }
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包..."); setProgress(resumingFromBootstrap ? 72 : 10,
offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Fetching and verifying version manifest...")
: QCoreApplication::translate("Updater", "Verifying offline update package..."));
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache"); const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
if (resumingFromBootstrap) { if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。"); return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."));
} else if (offlinePackagePath.isEmpty()) { } else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.getManifest(appId, channel, targetVersion, targetVersionId);
} }
if (!logic.verifyManifestSignature()) { if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。"); return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"),
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid"); QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."));
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
"manifest_signature_invalid");
} }
QStringList obsoletePaths; QStringList obsoletePaths;
if (!resumingFromBootstrap && fromVersion != targetVersion) { if (!resumingFromBootstrap && fromVersion != targetVersion) {
@@ -274,45 +308,62 @@ int main(int argc, char* argv[])
} }
if (!logic.saveManifestCache(manifestCacheDir)) { if (!logic.saveManifestCache(manifestCacheDir)) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。"); return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed"); QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
"manifest_cache_failed");
} }
if (!resumingFromBootstrap) { if (!resumingFromBootstrap) {
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件..."); setProgress(25,
offlinePackagePath.isEmpty()
? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
: QCoreApplication::translate("Updater", "Preparing offline package files..."));
if (offlinePackagePath.isEmpty()) if (offlinePackagePath.isEmpty())
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
if (logic.getFileList().isEmpty()) if (logic.getFileList().isEmpty())
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list"); return fail(QCoreApplication::translate("Updater", "No Files to Update"),
QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
"empty_file_list");
QStorageInfo storage(updateDir); QStorageInfo storage(updateDir);
storage.refresh(); storage.refresh();
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths); const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
const qint64 availableBytes = storage.bytesAvailable(); const qint64 availableBytes = storage.bytesAvailable();
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) { if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
return fail("磁盘空间不足", return fail(QCoreApplication::translate("Updater", "Insufficient Disk Space"),
QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n" 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))), .arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
"disk_space_insufficient"); "disk_space_insufficient");
} }
const QString stagingDir = transaction.stagingDir(); const QString stagingDir = transaction.stagingDir();
setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").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 (!offlinePackagePath.isEmpty()) {
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir)) if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid"); return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"),
logic.offlineError(),
"offline_package_invalid");
} else { } else {
if (!logic.downloadAllFiles(stagingDir, targetDir)) { if (!logic.downloadAllFiles(stagingDir, targetDir)) {
logic.reportDownloadResult(appId, channel, targetVersion, false); logic.reportDownloadResult(appId, channel, targetVersion, false);
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed"); return fail(QCoreApplication::translate("Updater", "Download Failed"),
QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network and try again."),
"download_failed");
} }
logic.reportDownloadResult(appId, channel, targetVersion, true); logic.reportDownloadResult(appId, channel, targetVersion, true);
} }
setProgress(58, "正在校验完整版本文件..."); setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files..."));
if (!logic.validateLocalFiles(stagingDir, targetDir)) if (!logic.validateLocalFiles(stagingDir, targetDir))
return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed"); return fail(QCoreApplication::translate("Updater", "File Verification Failed"),
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
"staging_verify_failed");
QStringList changedPaths; QStringList changedPaths;
QDir stagingRoot(stagingDir); QDir stagingRoot(stagingDir);
@@ -324,24 +375,35 @@ int main(int argc, char* argv[])
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable); runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
for (const QString& path : changedPaths) { for (const QString& path : changedPaths) {
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0) if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").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)) if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "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, "正在关闭主程序..."); setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
if (!FileHelper::killProcess(mainExecutable)) if (!FileHelper::killProcess(mainExecutable))
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed"); return fail(QCoreApplication::translate("Updater", "Cannot Close Main Application"),
QCoreApplication::translate("Updater", "%1 is still running. Please close it manually and try again.").arg(mainExecutable),
"mainapp_close_failed");
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...") setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 files to be deleted...")
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size())); .arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
if (!transaction.backupCurrentFiles()) if (!transaction.backupCurrentFiles())
return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "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(); const QString planFile = bootstrapPlanFile();
QSaveFile plan(planFile); QSaveFile plan(planFile);
if (!plan.open(QIODevice::WriteOnly)) if (!plan.open(QIODevice::WriteOnly))
return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed"); 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 auto writePlanItem = [&](char operation, const QString& path) {
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n'; const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
return plan.write(line) == line.size(); return plan.write(line) == line.size();
@@ -349,43 +411,55 @@ int main(int argc, char* argv[])
for (const QString& path : changedPaths) { for (const QString& path : changedPaths) {
if (!writePlanItem('C', path)) { if (!writePlanItem('C', path)) {
plan.cancelWriting(); plan.cancelWriting();
return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "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) { for (const QString& path : obsoletePaths) {
if (!writePlanItem('D', path)) { if (!writePlanItem('D', path)) {
plan.cancelWriting(); plan.cancelWriting();
return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "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()) if (!plan.commit() || !transaction.markAwaitingBootstrap())
return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "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, "正在将安装工作移交给 Bootstrap..."); setProgress(78, QCoreApplication::translate("Updater", "Delegating installation to Bootstrap..."));
if (!launchBootstrap("install")) if (!launchBootstrap("install"))
return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed"); return fail(QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
QCoreApplication::translate("Updater", "Cannot start the standalone update handoff program: %1").arg(bootstrapPath),
"bootstrap_start_failed");
progress.close(); progress.close();
return 0; return 0;
} }
setProgress(82, "正在校验 Bootstrap 安装结果..."); setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result..."));
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir)) if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。"); return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"),
QCoreApplication::translate("Updater", "New version files failed verification after installation."));
for (const QString& path : transaction.obsoletePaths()) { for (const QString& path : transaction.obsoletePaths()) {
if (QFile::exists(QDir(targetDir).filePath(path))) if (QFile::exists(QDir(targetDir).filePath(path)))
return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path)); return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
} }
setProgress(89, "正在保存新版本状态..."); setProgress(89, QCoreApplication::translate("Updater", "Saving new version state..."));
if (!config.setValue("App", "current_version", targetVersion) if (!config.setValue("App", "current_version", targetVersion)
|| config.getValue("App", "current_version") != targetVersion) || config.getValue("App", "current_version") != targetVersion)
return delegateRollback("状态保存失败", "无法保存当前版本号。"); return delegateRollback(QCoreApplication::translate("Updater", "State Save Failed"),
QCoreApplication::translate("Updater", "Cannot save the current version number."));
const QString healthFile = transaction.healthFile(); const QString healthFile = transaction.healthFile();
QFile::remove(healthFile); QFile::remove(healthFile);
setProgress(94, "正在启动新版本并等待健康确认..."); setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation..."));
if (!launchMainApp(healthFile)) if (!launchMainApp(healthFile))
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable)); return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"),
QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable));
QElapsedTimer healthTimer; QElapsedTimer healthTimer;
healthTimer.start(); healthTimer.start();
@@ -394,17 +468,23 @@ int main(int argc, char* argv[])
QThread::msleep(100); QThread::msleep(100);
} }
if (!QFile::exists(healthFile)) if (!QFile::exists(healthFile))
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs)); return delegateRollback(QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
QCoreApplication::translate("Updater", "The new version did not complete startup health confirmation within %1 milliseconds.")
.arg(healthCheckTimeoutMs));
setProgress(99, "正在提交更新事务..."); setProgress(99, QCoreApplication::translate("Updater", "Committing update transaction..."));
if (!transaction.commit()) if (!transaction.commit())
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。"); 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(healthFile);
QFile::remove(bootstrapPlanFile()); QFile::remove(bootstrapPlanFile());
reportUpdateResult(true); reportUpdateResult(true);
progress.setValue(100); progress.setValue(100);
progress.close(); progress.close();
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion)); QMessageBox::information(nullptr,
QCoreApplication::translate("Updater", "Update Complete"),
QCoreApplication::translate("Updater", "The software has been successfully updated to %1 and passed the startup health check.")
.arg(targetVersion));
return 0; return 0;
} }
-1
View File
@@ -6,7 +6,6 @@
"client_protocol": "3", "client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "", "license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026", "client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000", "request_timeout_ms": "5000",
"temp_folder": "update_temp", "temp_folder": "update_temp",
+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----- -----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX1FSi06eP8XhX4B3Oy6
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l FzfkTe3FBIg6OjWpT1541LivRTRt9HXZ30nKuIs5itXBzBQPMU8hsfR0MD9nfPG/
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ NlajfZzqbyxAJlUMeThJiwtyz98wv3VE1hS2Bwuc3mbmtfU0zl/MoPdWrluL3x3C
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv bt0ylL00rLBuvjgG21zDflVqj3w9CwpKHFsNrSYoI6vOFX9YrRZ9tKcPEkSRPqts
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs 411QkAQLtMiOMIIhNe5aFIL7doCnglx32hJeHNCTUSxjM9VyHUTEj8wKN/6Gy5iP
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ YZGRafeiJZ32RRIHyssereAg3Xe/3scd+tbFNNYP9xrRhRgDacmkSCBodnbJd4Qc
TwIDAQAB rQIDAQAB
-----END PUBLIC KEY----- -----END PUBLIC KEY-----
+3
View File
@@ -0,0 +1,3 @@
{
"api_base_url": "http://192.168.1.158:8000"
}
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/simcae">
<file>server_config.json</file>
</qresource>
</RCC>
+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.
+992
View File
@@ -0,0 +1,992 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS>
<TS version="2.1" language="zh_CN">
<context>
<name>Bootstrap</name>
<message>
<location filename="../Bootstrap/main.cpp" line="22"/>
<source>Update Handoff Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Bootstrap/main.cpp" line="139"/>
<source>Bootstrap arguments are incomplete.</source>
<translation>Bootstrap </translation>
</message>
<message>
<location filename="../Bootstrap/main.cpp" line="158"/>
<source>The update plan is missing or invalid.</source>
<translation></translation>
</message>
<message>
<location filename="../Bootstrap/main.cpp" line="195"/>
<source>Cannot restart Updater.</source>
<translation> Updater</translation>
</message>
<message>
<location filename="../Bootstrap/main.cpp" line="197"/>
<source>
Failed file: %1</source>
<translation>
%1</translation>
</message>
</context>
<context>
<name>ConfigHelper</name>
<message>
<location filename="../Common/ConfigHelper.cpp" line="219"/>
<source>The user canceled the administrator permission confirmation.</source>
<translation></translation>
</message>
<message>
<location filename="../Common/ConfigHelper.cpp" line="220"/>
<source>Windows error %1</source>
<translation>Windows %1</translation>
</message>
<message>
<location filename="../Common/ConfigHelper.cpp" line="230"/>
<source>Administrator Permission Required</source>
<translation></translation>
</message>
<message>
<location filename="../Common/ConfigHelper.cpp" line="231"/>
<source>The current installation directory requires administrator permission to save configuration.
Target file: %1
Reason: %2
Click OK, then choose Yes in the Windows permission confirmation dialog.</source>
<translation>
%1
%2
Windows </translation>
</message>
<message>
<location filename="../Common/ConfigHelper.cpp" line="238"/>
<source>The user canceled the administrator permission request.</source>
<translation></translation>
</message>
</context>
<context>
<name>DeviceIdentityHelper</name>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="54"/>
<source>OpenSSL is unavailable, so device credential signature cannot be verified.</source>
<translation> OpenSSL</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="62"/>
<source>Device public key is missing: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="73"/>
<source>Device public key is invalid: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="93"/>
<source>Device credential signature is invalid. The local identity file may not match this server.</source>
<translation></translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="118"/>
<source>Device credential file is not valid JSON: %1</source>
<translation> JSON%1</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="142"/>
<source>Device credential does not match this application, channel, license, installation or device.</source>
<translation>License</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="149"/>
<source>License has expired. Please ask the administrator to issue a new License.</source>
<translation>License License</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="182"/>
<source>app_id is empty in app_config.json.</source>
<translation>app_config.json app_id </translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="186"/>
<source>channel is empty in app_config.json.</source>
<translation>app_config.json channel </translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="190"/>
<source>Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000</source>
<translation> config/server_config.json Launcherhttp://192.168.229.128:8000</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="196"/>
<source>client_token is empty. Copy the client_token generated by the admin page into app_config.json.</source>
<translation>client_token client_token app_config.json</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="202"/>
<source>License is empty. Create or select a License in the admin page, then copy the generated client configuration.</source>
<translation>License License</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="213"/>
<source>Cannot save installation id to %1: %2</source>
<translation> ID %1%2</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="263"/>
<source>Device identity request failed (HTTP %1): %2</source>
<translation>HTTP %1%2</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="273"/>
<source>Server returned an invalid device identity response.</source>
<translation></translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="288"/>
<source>Cannot save device credential to %1: %2</source>
<translation> %1%2</translation>
</message>
<message>
<location filename="../Common/DeviceIdentityHelper.cpp" line="296"/>
<source>Cannot save server device id to %1: %2</source>
<translation> ID %1%2</translation>
</message>
</context>
<context>
<name>Launcher</name>
<message>
<location filename="../Launcher/main.cpp" line="32"/>
<source>Checking for software updates...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="33"/>
<source>Marsco Launcher</source>
<translation>Marsco </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="63"/>
<source>Please enter the License for %1:</source>
<translation> %1 License</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="64"/>
<location filename="../Launcher/main.cpp" line="66"/>
<source>the application</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="65"/>
<source>%1
Please enter a new License for %2:</source>
<translation>%1
%2 License</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="69"/>
<source>Enter License</source>
<translation> License</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="77"/>
<source>License Required</source>
<translation> License</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="78"/>
<source>A License provided by the administrator is required for the first launch.</source>
<translation> License</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="83"/>
<source>License Cannot Be Empty</source>
<translation>License </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="84"/>
<source>Please paste the License created in the admin page.</source>
<translation> License</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="90"/>
<source>Failed to Save License</source>
<translation> License </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="91"/>
<source>Cannot write the configuration file:
%1
%2</source>
<translation>
%1
%2</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="95"/>
<source>Verifying License...</source>
<translation> License...</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="120"/>
<source>Device Authentication Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="125"/>
<source>The current License cannot be used: %1</source>
<translation> License 使%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="147"/>
<source>The device or License authorization is invalid.</source>
<translation> License </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="151"/>
<source>The current authorization was rejected by the server: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="155"/>
<source>License Saved</source>
<translation>License </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="156"/>
<source>Please restart Launcher to complete device authorization and update checking.</source>
<translation> Launcher </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="161"/>
<source>Authorization Rejected</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="176"/>
<source>Select Offline Update Package</source>
<translation>线</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="178"/>
<source>Marsco offline update package (*.upd)</source>
<translation>Marsco 线 (*.upd)</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="186"/>
<location filename="../Launcher/main.cpp" line="324"/>
<source>Offline Update</source>
<translation>线</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="187"/>
<source>No offline update package was selected.</source>
<translation>线</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="207"/>
<source>Verifying local runtime policy...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="215"/>
<location filename="../Launcher/main.cpp" line="223"/>
<location filename="../Launcher/main.cpp" line="242"/>
<source>Cannot Start</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="216"/>
<source>The local version policy is invalid: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="224"/>
<source>The local version policy has expired. Please connect to the network or contact the administrator.</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="231"/>
<source>Current Version Cannot Run</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="232"/>
<source>The current version %1 has been disabled by the administrator.</source>
<translation> %1 </translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="243"/>
<source>Cannot read the local state: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="250"/>
<location filename="../Launcher/main.cpp" line="258"/>
<source>Security Check Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="251"/>
<source>A version policy sequence rollback was detected. Startup has been blocked.</source>
<translation>退</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="259"/>
<source>A possible system time rollback was detected. Startup has been blocked.</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="267"/>
<source>Version Rollback</source>
<translation>退</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="268"/>
<source>Update Required</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="268"/>
<source>New Version Available</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="271"/>
<source>The administrator provided version %1 as the rollback target. Downgrade now?</source>
<translation> %1 退</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="272"/>
<source>Version %1 is available. Update now?</source>
<translation> %1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="273"/>
<source>
Target version: %1</source>
<translation>
%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="284"/>
<location filename="../Launcher/main.cpp" line="348"/>
<source>Startup Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="285"/>
<location filename="../Launcher/main.cpp" line="349"/>
<source>Cannot start the main application: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="294"/>
<source>Updater Startup Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="295"/>
<source>Cannot start the updater: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="303"/>
<source>Caching signed version manifest...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="310"/>
<source>Manifest Cache Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="311"/>
<source>Cannot cache the signed manifest for the current version: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="319"/>
<source>Server Unavailable</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="320"/>
<source>Cannot connect to the update server. Import an offline update package?</source>
<translation>线</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="325"/>
<source>No offline update package was selected, or the updater could not be started.</source>
<translation>线</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="335"/>
<source>Network Unavailable</source>
<translation></translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="336"/>
<source>Cannot connect to the update server, and the current policy does not allow offline startup.</source>
<translation>线</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="341"/>
<source>The application is up to date. Starting...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Launcher/main.cpp" line="342"/>
<source>Offline mode is active. Starting...</source>
<translation>线...</translation>
</message>
</context>
<context>
<name>Updater</name>
<message>
<location filename="../Updater/main.cpp" line="45"/>
<source>Invalid Offline Package</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="57"/>
<source>Updater Argument Error</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="58"/>
<source>The updater is missing online update arguments or an offline update package.</source>
<translation>线线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="78"/>
<source>Offline Package Not Applicable</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="79"/>
<source>The update package application or channel does not match the local configuration.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="88"/>
<source>Offline Update Rejected</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="89"/>
<source>The local signed policy has expired, forbids offline updates, or does not allow the target version.</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="96"/>
<source>Downgrade Forbidden</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="97"/>
<source>The current signed policy does not allow installing an offline package with a lower version.</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="109"/>
<location filename="../Updater/main.cpp" line="117"/>
<source>Update Recovery Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="110"/>
<source>An unfinished update was detected, but the old version could not be restored: %1
Do not continue running the software. Please contact the administrator.</source>
<translation>%1
</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="118"/>
<source>The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="125"/>
<source>Preparing update...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="126"/>
<source>Updating to %1</source>
<translation> %1</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="155"/>
<source>Preparing download...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="156"/>
<source>Downloading: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="218"/>
<source>Delegating rollback to Bootstrap...</source>
<translation> Bootstrap...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="227"/>
<source>
Cannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator.</source>
<translation>
Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="237"/>
<source>Transaction Resume Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="238"/>
<source>Cannot read the Bootstrap update transaction: %1</source>
<translation> Bootstrap %1</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="251"/>
<source>Update Rolled Back</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="253"/>
<source>The new version failed to install or start. The old version has been restored and started automatically.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="254"/>
<source>The old files were restored, but the old version number could not be written back. Please check configuration directory permissions.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="261"/>
<source>Automatic Rollback Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="262"/>
<source>Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator.</source>
<translation>Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="266"/>
<location filename="../Updater/main.cpp" line="272"/>
<source>Update Preparation Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="267"/>
<source>Cannot create the update transaction directory or save the transaction state.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="273"/>
<source>Cannot save the offline transaction marker.</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="279"/>
<source>Fetching and verifying version manifest...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="280"/>
<source>Verifying offline update package...</source>
<translation>线...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="284"/>
<location filename="../Updater/main.cpp" line="311"/>
<location filename="../Updater/main.cpp" line="313"/>
<source>Manifest Cache Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="285"/>
<source>Cannot read the signed manifest cache after Bootstrap installation.</source>
<translation>Bootstrap Manifest </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="291"/>
<location filename="../Updater/main.cpp" line="293"/>
<source>Security Verification Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="292"/>
<source>Cannot reverify the manifest signature after Bootstrap installation.</source>
<translation>Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="294"/>
<source>The version manifest signature is invalid. The update has stopped. Please contact the administrator.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="312"/>
<source>Cannot save the new version manifest cache.</source>
<translation> Manifest </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="314"/>
<source>Cannot save the new version manifest cache. The update has stopped.</source>
<translation> Manifest </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="321"/>
<source>Fetching secure download URLs...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="322"/>
<source>Preparing offline package files...</source>
<translation>线...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="326"/>
<source>No Files to Update</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="327"/>
<source>The server did not return any version files. The update has stopped.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="335"/>
<source>Insufficient Disk Space</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="336"/>
<source>The update requires at least %1 of free space, but the installation drive currently has only %2.
The required space includes downloaded files, old version backups, and a safety margin.</source>
<translation> %1 %2
</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="345"/>
<source>Downloading and verifying %1 version files...</source>
<translation> %1 ...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="346"/>
<source>Extracting and verifying %1 offline files...</source>
<translation> %1 线...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="349"/>
<source>Offline Package Extraction Failed</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="355"/>
<source>Download Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="356"/>
<source>Some files failed to download or failed SHA-256 verification. Please check the network and try again.</source>
<translation> SHA-256 </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="362"/>
<source>Verifying complete version files...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="364"/>
<source>File Verification Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="365"/>
<source>The staged files do not match the version manifest. The update has stopped.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="378"/>
<source>Bootstrap Cannot Self-Update</source>
<translation>Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="379"/>
<source>This version contains a new %1. Please upgrade this component with the installer, then publish the business version again.</source>
<translation> %1使</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="384"/>
<source>Transaction Recording Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="385"/>
<source>Cannot save the list of verified or pending deletion files. The update has stopped.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="388"/>
<source>Closing the main application...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="390"/>
<source>Cannot Close Main Application</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="391"/>
<source>%1 is still running. Please close it manually and try again.</source>
<translation>%1 </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="394"/>
<source>Backing up %1 files to be changed, including %2 files to be deleted...</source>
<translation> %1 %2 ...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="397"/>
<source>Backup Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="398"/>
<source>Cannot back up current version files. The new version has not been installed. Please check disk space and directory permissions.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="404"/>
<location filename="../Updater/main.cpp" line="414"/>
<location filename="../Updater/main.cpp" line="422"/>
<location filename="../Updater/main.cpp" line="428"/>
<source>Bootstrap Handoff Preparation Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="405"/>
<source>Cannot create the Bootstrap file plan.</source>
<translation> Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="415"/>
<source>Cannot write the Bootstrap copy plan.</source>
<translation> Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="423"/>
<source>Cannot write the Bootstrap deletion plan.</source>
<translation> Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="429"/>
<source>Cannot commit the Bootstrap file plan or transaction state.</source>
<translation> Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="432"/>
<source>Delegating installation to Bootstrap...</source>
<translation> Bootstrap...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="434"/>
<source>Bootstrap Startup Failed</source>
<translation>Bootstrap </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="435"/>
<source>Cannot start the standalone update handoff program: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="441"/>
<source>Verifying Bootstrap installation result...</source>
<translation> Bootstrap ...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="443"/>
<source>Installation Verification Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="444"/>
<source>New version files failed verification after installation.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="447"/>
<source>Obsolete File Cleanup Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="448"/>
<source>An obsolete file still exists: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="451"/>
<source>Saving new version state...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="454"/>
<source>State Save Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="455"/>
<source>Cannot save the current version number.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="459"/>
<source>Starting the new version and waiting for health confirmation...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="461"/>
<source>Startup Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="462"/>
<source>%1 cannot be started.</source>
<translation>%1 </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="471"/>
<source>Startup Confirmation Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="472"/>
<source>The new version did not complete startup health confirmation within %1 milliseconds.</source>
<translation> %1 </translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="475"/>
<source>Committing update transaction...</source>
<translation>...</translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="477"/>
<source>Transaction Commit Failed</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="478"/>
<source>The new version has started, but the update transaction could not be committed.</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="486"/>
<source>Update Complete</source>
<translation></translation>
</message>
<message>
<location filename="../Updater/main.cpp" line="487"/>
<source>The software has been successfully updated to %1 and passed the startup health check.</source>
<translation> %1</translation>
</message>
</context>
<context>
<name>UpdaterLogic</name>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="356"/>
<source>Cannot open the offline update package</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="357"/>
<source>The offline package format identifier is invalid</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="359"/>
<source>The offline package header is incomplete</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="362"/>
<source>The offline package header length is invalid</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="365"/>
<source>The offline package header JSON is invalid</source>
<translation>线 JSON </translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="370"/>
<source>The offline package RSA signature is invalid</source>
<translation>线 RSA </translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="372"/>
<source>The offline package signature metadata is invalid</source>
<translation>线</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="373"/>
<source>The manifest digest does not match the package signature</source>
<translation>Manifest </translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="375"/>
<source>The offline manifest is invalid</source>
<translation>线 Manifest </translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="378"/>
<source>Package information does not match manifest identity</source>
<translation> Manifest </translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="379"/>
<source>The offline manifest RSA signature is invalid</source>
<translation>线 Manifest RSA </translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="385"/>
<source>The offline package contains an unsafe path or out-of-range data: %1</source>
<translation>线%1</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="388"/>
<source>Cannot prepare offline file: %1</source>
<translation>线%1</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="389"/>
<source>Cannot create staged file: %1</source>
<translation>%1</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="391"/>
<source>Failed to read offline file: %1</source>
<translation>线%1</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="392"/>
<source>Offline file hash verification or write failed: %1</source>
<translation>线 Hash %1</translation>
</message>
<message>
<location filename="../Updater/UpdaterLogic.cpp" line="394"/>
<source>The offline package file count does not match the manifest</source>
<translation>线 Manifest </translation>
</message>
</context>
</TS>
-2943
View File
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
```
@@ -1,13 +1,24 @@
param( param(
[string]$SourceDir = "$PSScriptRoot/out/bin", [string]$SourceDir = "",
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ConfigFile, [string]$ConfigFile,
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClient", [string]$OutputDir = "",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip" [string]$ZipFile = ""
) )
$ErrorActionPreference = "Stop" $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 $source = (Resolve-Path $SourceDir).Path
$config = (Resolve-Path $ConfigFile).Path $config = (Resolve-Path $ConfigFile).Path
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json $settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
@@ -37,8 +48,27 @@ function Join-RelativePath([string]$Base, [string]$Child) {
return "$baseNorm/$childNorm" 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 = @( $requiredFields = @(
"app_id", "channel", "api_base_url", "current_version", "app_id", "channel", "current_version",
"client_token", "launch_token", "license_key", "client_token", "launch_token", "license_key",
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
) )
@@ -71,7 +101,7 @@ $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Extension -in @('.pdb', '.ilk') $_.Extension -in @('.pdb', '.ilk')
} }
if ($debugArtifacts) { if ($debugArtifacts) {
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)" throw "Source directory contains Debug artifacts. Clean the Release output directory and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
} }
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable $expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
@@ -85,16 +115,21 @@ if ($duplicateMain) {
} }
$manifestName = "manifest_$($settings.current_version).json" $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" $sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar) $manifestCandidates = @(
if (-not (Test-Path $sourceManifest)) { (Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName" (Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
if (Test-Path $legacySourceManifest) { (Join-Path $source "update/manifest_cache/$manifestName")
$sourceManifest = $legacySourceManifest ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
} $sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
} if (-not $sourceManifest -or -not (Test-Path $sourceManifest)) {
if (-not (Test-Path $sourceManifest)) { $searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." 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) { if (Test-Path $OutputDir) {
+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"
+54 -11
View File
@@ -1,15 +1,29 @@
param( param(
[string]$SourceDir = "$PSScriptRoot/out/bin", [string]$SourceDir = "",
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK", [string]$OutputDir = "",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", [string]$ZipFile = "",
[string]$SdkVersion = "0.1.0", [string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", [string]$ExampleConfig = "",
[switch]$IncludeDemoMainApp, [switch]$IncludeDemoMainApp,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
$ErrorActionPreference = "Stop" $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 $source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path $exampleConfigPath = (Resolve-Path $ExampleConfig).Path
@@ -24,7 +38,7 @@ foreach ($name in $requiredFiles) {
$publicKeyCandidates = @( $publicKeyCandidates = @(
(Join-Path $source "config/manifest_public_key.pem"), (Join-Path $source "config/manifest_public_key.pem"),
(Join-Path $source "manifest_public_key.pem"), (Join-Path $source "manifest_public_key.pem"),
(Join-Path $PSScriptRoot "config/manifest_public_key.pem") (Join-Path $RepoRoot "config/manifest_public_key.pem")
) )
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 $publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) { if (-not $publicKey) {
@@ -45,7 +59,9 @@ New-Item $OutputDir -ItemType Directory -Force | Out-Null
$binDir = Join-Path $OutputDir "bin" $binDir = Join-Path $OutputDir "bin"
$configDir = Join-Path $OutputDir "config" $configDir = Join-Path $OutputDir "config"
$scriptsDir = Join-Path $OutputDir "scripts" $scriptsDir = Join-Path $OutputDir "scripts"
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null $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") $excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" } if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
@@ -86,13 +102,37 @@ Get-ChildItem $source -Force | Where-Object {
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { $commonSourceDir = Join-Path $RepoRoot "Common"
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" $commonSourceFiles = @(
} | Sort-Object Name | Select-Object -First 1 "ConfigHelper.h",
if (-not $wordGuideSource) { "ConfigHelper.cpp",
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." "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 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-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 "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
@@ -104,6 +144,9 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in
sdk_type = "external-updater-runtime" sdk_type = "external-updater-runtime"
required_entry = "Launcher.exe" required_entry = "Launcher.exe"
contains_demo_main_app = [bool]$IncludeDemoMainApp contains_demo_main_app = [bool]$IncludeDemoMainApp
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 } | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
$zipParent = Split-Path $ZipFile -Parent $zipParent = Split-Path $ZipFile -Parent
+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"