Compare commits

...

13 Commits

57 changed files with 7200 additions and 6001 deletions
+6
View File
@@ -0,0 +1,6 @@
root = true
[*]
charset = utf-8-bom
trim_trailing_whitespace = true
insert_final_newline = true
+4
View File
@@ -22,11 +22,14 @@ Desktop.ini
*.pdf *.pdf
*.doc *.doc
*.docx *.docx
private/
pdf_requirements.txt
# C/C++ generated artifacts # C/C++ generated artifacts
*.obj *.obj
*.o *.o
*.pdb *.pdb
*.qm
*.ilk *.ilk
*.idb *.idb
*.tlog *.tlog
@@ -49,6 +52,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 -24
View File
@@ -1,55 +1,139 @@
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
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") # Copy local-only static resources to output bin folder. Final customer
# app_config.json is generated by the Go server when a release package is
# uploaded. Developers may create an untracked config/app_config.local.json for
# local debugging.
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(LOCAL_APP_CONFIG_FILE "${CONFIG_SOURCE_DIR}/app_config.local.json")
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")
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini") if(EXISTS "${LOCAL_APP_CONFIG_FILE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) configure_file("${LOCAL_APP_CONFIG_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 +145,13 @@ 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 "${MANIFEST_PUBLIC_KEY_FILE}")
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config) install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
endif() install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE})
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"
}
]
}
+7 -9
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
@@ -14,10 +13,10 @@ set(SRC
LocalStateHelper.cpp LocalStateHelper.cpp
TicketHelper.h TicketHelper.h
TicketHelper.cpp TicketHelper.cpp
UpdatePathPolicy.h
UpdatePathPolicy.cpp
IntegrityHelper.h IntegrityHelper.h
IntegrityHelper.cpp IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
) )
add_library(Common STATIC ${SRC}) add_library(Common STATIC ${SRC})
@@ -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()
+747 -60
View File
@@ -1,13 +1,328 @@
#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("SimCAE");
const QString kRegistryApplication = QStringLiteral("HubUpdateClient");
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 首次启动时导入注册表,之后以注册表为准。
Q_UNUSED(key);
return true;
}
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 operation needs administrator permission to modify a protected file.\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 +330,158 @@ 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();
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("SimCAE/HubUpdateClient/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,29 @@ 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);
if (!isRegistryManagedConfigKey(key))
return QString();
QString value;
if (readRegistryValue(key, &value))
return value;
value = readFileValue(key).trimmed();
if (!value.isEmpty())
return value;
return readEmbeddedValue(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()
@@ -149,25 +827,34 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
}; };
copyText("App", "app_id"); copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App"); copyText("App", "product_code", ini.value("App/app_id").toString());
copyText("App", "app_name", "SimCAE");
copyText("App", "channel", "stable"); copyText("App", "channel", "stable");
copyText("App", "current_version", "1.0.0"); copyText("App", "current_version", "1.0.0");
copyText("App", "client_protocol", "3"); copyText("App", "client_protocol", "3");
copyText("App", "launch_token");
copyText("License", "license_key");
copyText("Server", "api_base_url");
copyText("Server", "client_token"); copyText("Server", "client_token");
copyText("App", "launch_token");
copyText("Server", "api_base_url");
copyText("Update", "request_timeout_ms", "5000"); copyText("Update", "request_timeout_ms", "5000");
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");
copyText("Security", "require_manifest_signature", "false");
copyText("Security", "verify_installed_on_start", "false");
copyText("Platform", "abi");
#ifdef Q_OS_WIN
config.insert("platform", "windows"); config.insert("platform", "windows");
config.insert("arch", "x64"); #elif defined(Q_OS_LINUX)
config.insert("platform", "linux");
#else
config.insert("platform", "unknown");
#endif
config.insert("arch", "x86_64");
QDir().mkpath(QFileInfo(m_configPath).path()); QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath); QSaveFile output(m_configPath);
+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;
}; };
-49
View File
@@ -1,49 +0,0 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QUuid>
#include <QTimer>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
#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::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
const QString trimmedBase=base.trimmed();
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
ConfigHelper& config=ConfigHelper::instance();
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
}
-15
View File
@@ -1,15 +0,0 @@
#pragma once
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, 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;
} }
+103 -36
View File
@@ -4,33 +4,53 @@
#include <QFile> #include <QFile>
#include <QDir> #include <QDir>
#include <QApplication> #include <QApplication>
#include <QJsonParseError>
#include <QTimer> #include <QTimer>
#include <QUrl>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, namespace {
std::function<void(int code, const QJsonObject& resp)> callback)
int requestTimeoutMs()
{ {
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
QEventLoop loop;
bool timeoutOk = false; bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000; if (!timeoutOk || timeoutMs < 1000)
timeoutMs = 5000;
return timeoutMs;
}
void applyCommonHeaders(QNetworkRequest& req, const QString& bearerToken)
{
const QString clientToken = ConfigHelper::instance().getValue(QStringLiteral("Server"), QStringLiteral("client_token")).trimmed();
if (!clientToken.isEmpty())
req.setRawHeader("X-Client-Token", clientToken.toUtf8());
const QString token = bearerToken.trimmed();
if (!token.isEmpty())
req.setRawHeader("Authorization", QByteArray("Bearer ") + token.toUtf8());
}
void readJsonReply(QNetworkReply* reply, int* retCode, QJsonObject* retObj)
{
*retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(respData, &parseError);
if (parseError.error == QJsonParseError::NoError && document.isObject())
*retObj = document.object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << *retCode << "detail:" << reply->errorString();
}
}
void waitForReply(const QString& url, QNetworkReply* reply)
{
QEventLoop loop;
QTimer timer; QTimer timer;
timer.setSingleShot(true); timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() { QObject::connect(&timer, &QTimer::timeout, [&]() {
@@ -39,26 +59,73 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
reply->abort(); reply->abort();
} }
}); });
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs); timer.start(requestTimeoutMs());
loop.exec(); loop.exec();
timer.stop(); timer.stop();
}
} // namespace
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
postRequest(url, jsonBody, QString(), callback);
}
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
applyCommonHeaders(req, bearerToken);
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
waitForReply(url, reply);
int retCode = 0; int retCode = 0;
QJsonObject retObj; QJsonObject retObj;
readJsonReply(reply, &retCode, &retObj);
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll(); callback(retCode, retObj);
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData; reply->deleteLater();
retObj = QJsonDocument::fromJson(respData).object(); manager->deleteLater();
} }
if (reply->error() != QNetworkReply::NoError)
{ void HttpHelper::getRequest(const QString& url,
qDebug() << "Network error code:" << reply->error(); std::function<void(int code, const QJsonObject& resp)> callback)
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); {
} getRequest(url, QString(), callback);
}
void HttpHelper::getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req{QUrl(url)};
applyCommonHeaders(req, bearerToken);
qDebug() << "=== GET Request ===";
qDebug() << "Url:" << url;
QNetworkReply* reply = manager->get(req);
waitForReply(url, reply);
int retCode = 0;
QJsonObject retObj;
readJsonReply(reply, &retCode, &retObj);
callback(retCode, retObj); callback(retCode, retObj);
+9 -1
View File
@@ -7,11 +7,19 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QEventLoop> #include <QEventLoop>
#include <QDebug> #include <QDebug>
#include <functional>
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);
static void postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
}; };
+162 -38
View File
@@ -1,11 +1,13 @@
#include "IntegrityHelper.h" #include "IntegrityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QCoreApplication>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QSet> #include <QSet>
@@ -14,6 +16,32 @@
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
bool manifestFileRequired(const QJsonObject& item)
{
if (!item.contains(QStringLiteral("required")))
return true;
return item.value(QStringLiteral("required")).toBool(true);
}
} // namespace
IntegrityHelper::IntegrityHelper(const QString& installDir) IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {} : m_installDir(QDir::cleanPath(installDir)) {}
@@ -21,28 +49,13 @@ QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::safeRelativePath(const QString& path) const bool IntegrityHelper::safeRelativePath(const QString& path) const
{ {
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); return UpdatePathPolicy::isSafeRelativePath(path);
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
} }
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{ {
const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); return UpdatePathPolicy::isFullUpdateProtectedPath(
QSet<QString> protectedPaths{ path, ConfigHelper::instance().runtimeRelativePath());
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(p);
} }
QString IntegrityHelper::sha256(const QString& filePath) const QString IntegrityHelper::sha256(const QString& filePath) const
@@ -57,18 +70,31 @@ QString IntegrityHelper::sha256(const QString& filePath) const
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; Q_UNUSED(payload); Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
return false;
#else #else
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem"); QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
if (!QFile::exists(keyPath)) if (!QFile::exists(keyPath))
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem"); keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
QFile keyFile(keyPath); QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; } if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio); if (bio) BIO_free(bio);
if (!key) { m_error = "manifest public key invalid"; return false; } if (!key) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
@@ -76,7 +102,10 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; && EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key); EVP_PKEY_free(key);
if (!ok) m_error = "manifest RSA signature invalid"; if (!ok) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
}
return ok; return ok;
#endif #endif
} }
@@ -85,6 +114,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(
@@ -92,48 +123,130 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
if (!QFile::exists(cachePath)) if (!QFile::exists(cachePath))
cachePath = legacyCachePath; cachePath = legacyCachePath;
QFile cache(cachePath); QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; } if (!cache.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is missing. Stage: installed version verification. Version: %1. Expected cache file: %2. This cache is created after the same version is published or installed successfully.")
.arg(version, cachePath);
return false;
}
QJsonParseError wrapperError; QJsonParseError wrapperError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = "manifest cache JSON invalid"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, wrapperError.errorString());
return false;
} }
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); if (manifestText.isEmpty())
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false; manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString manifestSha256 = wrapper.value("manifestSha256").toString(
wrapper.value("manifest_sha256").toString());
const QString signature = wrapper.value("signature").toString(
wrapper.value("manifest").toObject().value("signature").toString());
const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty());
if (manifestText.isEmpty()) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath);
return false;
}
if (!manifestSha256.isEmpty()) {
const QString actualSha = QString::fromLatin1(
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
.arg(version, manifestSha256, actualSha);
return false;
}
}
if (signedManifest && !signature.isEmpty()) {
if (!verifySignature(manifestText, signature)) return false;
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
.arg(version);
return false;
}
QJsonParseError manifestError; QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
m_error = "signed manifest payload invalid"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
"Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, manifestError.errorString());
return false;
} }
const QJsonObject manifest = manifestDoc.object(); const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId const QString manifestProduct = manifest.value("productCode").toString(
manifest.value("app_id").toString());
if (manifestProduct != appId
|| manifest.value("channel").toString() != channel || manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) { || manifest.value("version").toString() != version) {
m_error = "manifest identity does not match local application"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifestProduct,
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
} }
QSet<QString> declaredExecutables; QSet<QString> declaredExecutables;
QSet<QString> optionalComponentDirs;
for (const QJsonValue& value : manifest.value("files").toArray()) { for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject(); const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; } if (!safeRelativePath(path)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
.arg(version, path);
return false;
}
if (UpdatePathPolicy::isExecutableOrLibrary(path))
declaredExecutables.insert(path.toCaseFolded());
if (!manifestFileRequired(item)) {
const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path());
if (!dir.isEmpty() && dir != QStringLiteral("."))
optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded());
continue;
}
if (runtimeProtectedPath(path)) continue; if (runtimeProtectedPath(path)) continue;
const QString fullPath = QDir(m_installDir).filePath(path); const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; } if (!QFile::exists(fullPath)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"A required installed file is missing. Stage: installed version verification. Version: %1. Manifest path: %2. Checked path: %3. The local installation no longer matches the published version.")
.arg(version, path, fullPath);
return false;
}
const qint64 expectedSize = item.contains("sizeBytes")
? item.value("sizeBytes").toVariant().toLongLong()
: item.value("size").toVariant().toLongLong();
if ((item.contains("sizeBytes") || item.contains("size"))
&& QFileInfo(fullPath).size() != expectedSize) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes")
.arg(version, path, fullPath,
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
return false;
}
const QString expected = item.value("sha256").toString(); const QString expected = item.value("sha256").toString();
const QString actual = sha256(fullPath); const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
m_error = "file hash mismatch: " + path; return false; m_error = QCoreApplication::translate("IntegrityHelper",
"Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected SHA-256: %4\nActual SHA-256: %5\nThis means the installed file is different from the version that was published or installed. If this is a developer test machine, check whether the local Release directory was recompiled or overwritten after publishing.")
.arg(version, path, fullPath, expected,
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
return false;
} }
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
} }
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));
@@ -144,9 +257,20 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|| folded.startsWith(runtimePrefix + "/update_temp/")); || folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/") if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue; || runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); bool optionalComponentFile = false;
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { for (const QString& prefix : optionalComponentDirs) {
m_error = "undeclared executable or plugin: " + relative; return false; if (folded.startsWith(prefix)) {
optionalComponentFile = true;
break;
}
}
if (optionalComponentFile)
continue;
if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
return false;
} }
} }
return true; return true;
+36 -20
View File
@@ -1,28 +1,28 @@
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
#include <QFile> #include "ConfigHelper.h"
#include <QFileInfo> #include <QCoreApplication>
#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()},
@@ -32,7 +32,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
m_loaded = true; m_loaded = true;
if (!saveState()) if (!saveState())
{ {
m_error = QString("Cannot write new state file: %1").arg(m_filePath); m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot create local state file: %1. Error: %2.")
.arg(m_filePath, m_error);
return false; return false;
} }
return true; return true;
@@ -40,7 +43,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
{ {
m_error = QString("Cannot open state file: %1").arg(m_filePath); m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot open local state file: %1. Error: %2.")
.arg(m_filePath, file.errorString());
return false; return false;
} }
@@ -51,7 +57,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) if (parseError.error != QJsonParseError::NoError || !doc.isObject())
{ {
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString()); m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state file is not valid JSON. File: %1. JSON error: %2.")
.arg(m_filePath, parseError.errorString());
return false; return false;
} }
@@ -63,17 +72,24 @@ bool LocalStateHelper::loadState(const QString& relativePath)
bool LocalStateHelper::saveState() const bool LocalStateHelper::saveState() const
{ {
if (!m_loaded) if (!m_loaded)
return false;
QFile file(m_filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
{ {
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state has not been loaded.");
return false; return false;
} }
QJsonDocument doc(m_state); QJsonDocument doc(m_state);
file.write(doc.toJson(QJsonDocument::Indented)); QString writeError;
file.close(); if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
{
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot save local state file: %1. Error: %2.")
.arg(m_filePath, writeError);
return false;
}
m_error.clear();
return true; 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;
}; };
+87 -14
View File
@@ -1,10 +1,12 @@
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDir>
#include <QFile> #include <QFile>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile>
#include <algorithm> #include <algorithm>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
@@ -13,14 +15,38 @@
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); const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; } QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open signed version policy file: %1. Error: %2.")
.arg(path, file.errorString());
return false;
}
QJsonParseError error; QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !doc.isObject()) { if (error.error != QJsonParseError::NoError || !doc.isObject()) {
m_error = "Invalid policy JSON: " + error.errorString(); return false; m_error = QCoreApplication::translate(
"PolicyHelper",
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
.arg(path, error.errorString());
return false;
} }
return loadPolicyObject(doc.object()); return loadPolicyObject(doc.object());
} }
@@ -33,10 +59,18 @@ 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 = QCoreApplication::translate(
"PolicyHelper",
"Cannot save signed version policy file: %1. Error: %2.")
.arg(path, writeError);
return false;
}
m_error.clear();
return true;
} }
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
@@ -66,34 +100,72 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
{ {
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"PolicyHelper",
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
return false;
#else #else
QString keyPath = m_baseDir + "/config/manifest_public_key.pem"; QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
QFile keyFile(keyPath); QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; } if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open version policy public key: %1. Error: %2.")
.arg(keyPath, keyFile.errorString());
return false;
}
const QByteArray keyData = keyFile.readAll(); const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio); if (bio) BIO_free(bio);
if (!key) { m_error = "Invalid policy public key"; return false; } if (!key) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy public key is invalid: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; && EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) m_error = "Invalid RSA policy signature"; if (!ok) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
}
return ok; return ok;
#endif #endif
} }
bool PolicyHelper::isValid() const bool PolicyHelper::isValid() const
{ {
if (!m_loaded) return false; if (!m_loaded) {
m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
return false;
}
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run", const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"}; "force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; } for (const QString& key : required) {
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false; if (!m_policy.contains(key)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy is missing required field: %1.")
.arg(key);
return false;
}
}
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy signature algorithm is unsupported: %1.")
.arg(m_policy.value("signature_alg").toString());
return false;
}
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString()); return verifySignature(payload, m_policy.value("signature").toString());
} }
@@ -107,6 +179,7 @@ bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); } bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); } bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); } bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
bool PolicyHelper::gitTagsEnabled() const { return isValid() && m_policy.value("git_tags_enabled").toBool(); }
bool PolicyHelper::isExpired() const { bool PolicyHelper::isExpired() const {
if (!isValid()) return true; if (!isValid()) return true;
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate); const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
+1
View File
@@ -17,6 +17,7 @@ public:
bool forceUpdate() const; bool forceUpdate() const;
bool allowRollback() const; bool allowRollback() const;
bool isOfflineAllowed() const; bool isOfflineAllowed() const;
bool gitTagsEnabled() const;
bool isExpired() const; bool isExpired() const;
qint64 policySeq() const; qint64 policySeq() const;
QString message() const; QString message() const;
+104 -14
View File
@@ -8,6 +8,7 @@
#include <QMessageAuthenticationCode> #include <QMessageAuthenticationCode>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
#include <QStringList>
#include <QUuid> #include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
@@ -20,8 +21,20 @@ 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)
{ {
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) { // Launcher 启动业务主程序前生成一次性 ticket。
if (errorMessage) *errorMessage = "ticket identity or secret is empty"; // ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
if (appId.isEmpty() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) {
QStringList missing;
if (appId.isEmpty()) missing.append(QStringLiteral("app_id"));
if (deviceId.isEmpty()) missing.append(QStringLiteral("device_id"));
if (version.isEmpty()) missing.append(QStringLiteral("current_version"));
if (secret.isEmpty()) missing.append(QStringLiteral("launch_token"));
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket because required fields are empty: %1. Check app_config.json, registry-imported configuration and device authorization.")
.arg(missing.join(QStringLiteral(", ")));
}
return false; return false;
} }
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
@@ -34,12 +47,25 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
}; };
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}}; QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets"); const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; } if (!QDir().mkpath(dirPath)) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket directory: %1.")
.arg(dirPath);
}
return false;
}
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
QSaveFile file(path); QSaveFile file(path);
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
if (errorMessage) *errorMessage = "cannot save ticket"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot save launch ticket file: %1. Error: %2.")
.arg(path, file.errorString());
}
return false; return false;
} }
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
@@ -51,24 +77,42 @@ 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)) {
if (errorMessage) *errorMessage = "ticket missing or already consumed"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is missing or has already been consumed. Ticket file: %1. Please start the application from Launcher.")
.arg(ticketPath);
}
return false; return false;
} }
QFile file(consumingPath); QFile file(consumingPath);
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(consumingPath); QFile::remove(consumingPath);
if (errorMessage) *errorMessage = "cannot read claimed ticket"; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot read claimed launch ticket: %1. Error: %2.")
.arg(consumingPath, file.errorString());
}
return false; 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()) {
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false; if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is not valid JSON. JSON error: %1.")
.arg(parseError.errorString());
}
return false;
} }
const QJsonObject wrapper = doc.object(); const QJsonObject wrapper = doc.object();
const QJsonObject payload = wrapper.value("payload").toObject(); const QJsonObject payload = wrapper.value("payload").toObject();
@@ -77,14 +121,60 @@ 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) {
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid"; *errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket signature is invalid. The launch_token used by Launcher and the main application is inconsistent, or the ticket content was changed.");
}
return false;
}
if (payload.value("app_id").toString() != expectedAppId) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket app_id does not match. Ticket app_id: %1. Expected app_id: %2.")
.arg(payload.value("app_id").toString(), expectedAppId);
}
return false;
}
if (payload.value("device_id").toString() != expectedDeviceId) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket device_id does not match. Ticket device_id: %1. Expected device_id: %2. Reauthorize the device from Launcher if the configuration was regenerated.")
.arg(payload.value("device_id").toString(), expectedDeviceId);
}
return false;
}
if (payload.value("version").toString() != expectedVersion) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket version does not match. Ticket version: %1. Expected version: %2.")
.arg(payload.value("version").toString(), expectedVersion);
}
return false;
}
if (!timeOk) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket time is invalid or expired. Issued at: %1. Expires at: %2. Current UTC time: %3.")
.arg(payload.value("issued_at").toString(),
payload.value("expires_at").toString(),
now.toString(Qt::ISODate));
}
return false;
}
if (payload.value("nonce").toString().isEmpty()) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket nonce is missing. The ticket is incomplete.");
}
return false; return false;
} }
return true; return true;
+127
View File
@@ -0,0 +1,127 @@
#include "UpdatePathPolicy.h"
#include <QDir>
#include <QFileInfo>
#include <QSet>
#include <QStringList>
namespace {
bool exactOrRuntimeMatch(const QString& folded, const QString& runtimePrefix,
const QSet<QString>& exactPaths)
{
if (exactPaths.contains(folded))
return true;
if (runtimePrefix.isEmpty() || !folded.startsWith(runtimePrefix + QStringLiteral("/")))
return false;
return exactPaths.contains(folded.mid(runtimePrefix.size() + 1));
}
bool prefixOrRuntimePrefixMatch(const QString& folded, const QString& runtimePrefix,
const QString& prefix)
{
if (folded.startsWith(prefix))
return true;
if (runtimePrefix.isEmpty())
return false;
return folded.startsWith(runtimePrefix + QStringLiteral("/") + prefix);
}
} // namespace
namespace UpdatePathPolicy {
QString normalizeRelativePath(const QString& path)
{
QString normalized = QDir::cleanPath(QDir::fromNativeSeparators(path.trimmed()));
if (normalized == QStringLiteral("."))
return QString();
while (normalized.startsWith(QStringLiteral("./")))
normalized = normalized.mid(2);
return normalized;
}
bool isSafeRelativePath(const QString& path)
{
const QString clean = normalizeRelativePath(path);
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != QStringLiteral("..")
&& !clean.startsWith(QStringLiteral("../")) && !clean.contains(QLatin1Char(':'));
}
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath)
{
const QString folded = normalizeRelativePath(path).toCaseFolded();
const QString runtimePrefix = normalizeRelativePath(runtimeRelativePath).toCaseFolded();
const QSet<QString> exactPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
if (exactOrRuntimeMatch(folded, runtimePrefix, exactPaths))
return true;
return prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update/"))
|| prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update_temp/"));
}
bool isIFWInstallerManagedPath(const QString& path)
{
const QString folded = normalizeRelativePath(path).toCaseFolded();
if (folded.isEmpty())
return false;
const bool rootFile = !folded.contains(QLatin1Char('/'));
if (rootFile && (folded == QStringLiteral("maintenancetool")
|| folded == QStringLiteral("maintenancetool.exe")
|| folded.startsWith(QStringLiteral("maintenancetool.")))) {
return true;
}
const QSet<QString> exactPaths{
QStringLiteral("components.xml"),
QStringLiteral("components.xml.new"),
QStringLiteral("components.xml.old"),
QStringLiteral("installation.xml"),
QStringLiteral("installation.dat"),
QStringLiteral("installer.dat"),
QStringLiteral("installer.ini"),
QStringLiteral("network.xml"),
QStringLiteral("repositories.xml"),
QStringLiteral("repositories.cfg"),
QStringLiteral("repository.xml")
};
if (exactPaths.contains(folded))
return true;
const QStringList prefixes{
QStringLiteral("installerresources/"),
QStringLiteral("installationinformation/"),
QStringLiteral("licenses/")
};
for (const QString& prefix : prefixes) {
if (folded.startsWith(prefix))
return true;
}
return false;
}
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath)
{
return isUpdaterRuntimeProtectedPath(path, runtimeRelativePath)
|| isIFWInstallerManagedPath(path);
}
bool isExecutableOrLibrary(const QString& path)
{
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
return suffix == QStringLiteral("exe") || suffix == QStringLiteral("dll");
}
} // namespace UpdatePathPolicy
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <QString>
namespace UpdatePathPolicy {
QString normalizeRelativePath(const QString& path);
bool isSafeRelativePath(const QString& path);
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath);
bool isIFWInstallerManagedPath(const QString& path);
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath);
bool isExecutableOrLibrary(const QString& path);
}
@@ -0,0 +1,23 @@
SimCAE Hub 客户端文档入口
========================
本目录记录 SimCAE Hub 的 Qt/C++ 客户端更新链路。当前方向是保留 Launcher / Updater / Bootstrap 的桌面客户端机制,适配 SimCAE Hub 当前 Go API,不用 Go 或 Web 技术重写客户端。
建议先按这个顺序阅读:
1. 01-客户端接入打包部署指南.md
说明客户端运行链路、配置字段、安装目录口径和接入限制。
2. 02-编译环境和第三方依赖说明.md
说明 Windows 和 Linux 下编译 Qt/C++ 客户端需要的工具、Qt、OpenSSL 和 CMake 命令。
3. ../打包成SDK.md
说明如何从 update-client 编译产物生成给 SIMCAE 开发者使用的 SDK 包。
4. ../config/server_config.json
编译进客户端资源的服务端地址配置,当前测试服务器是 http://192.168.1.158:18000。
5. ../scripts/ReadMe.txt
SDK 打包脚本和客户安装包打包脚本的简短说明。
客户端能力、接口链路、配置字段和接入限制统一看 01 文档。当前不包含邮箱、支付、告警、灰度发布等页面上没有的业务模块;崩溃报告作为旧系统兼容后端接口保留,具体看项目根目录的 项目细节.md。
@@ -0,0 +1,210 @@
# SimCAE Hub 客户端接入、打包和部署指南
本文说明 `update-client` 的当前实现。它保留 Launcher / Updater / Bootstrap 的桌面客户端机制,服务端协议使用 SimCAE Hub 当前 Go API。
## 1. 适用范围
当前客户端只覆盖项目已有页面和接口对应的能力:
1. 产品版本、软件发布、发布包和 Manifest。
2. 客户授权、在线命名用户席位和门户受控下载。
3. 在线检查更新、Manifest 拉取、受控下载、SHA-256 校验。
4. Manifest 签名验签、临时文件、断点重试、安装前后完整性校验。
邮箱、支付、灰度、告警等页面上没有的能力不属于当前范围。崩溃报告是 SimCAE Hub 保留的旧系统兼容后端接口,不属于 Launcher / Updater / Bootstrap 的更新链路;接入方需要崩溃上报时,按项目根目录 `项目细节.md` 里的崩溃报告接口说明调用。
## 2. 客户端程序组成
| 程序 | 作用 |
| --- | --- |
| `Launcher` | 客户日常启动入口,负责导入配置、使用 `client_token` 检查更新、启动 Updater 或主程序 |
| `Updater` | 负责拉取 Manifest、下载发布包、校验文件、准备安装事务 |
| `Bootstrap` | 负责在需要替换运行中文件时接管安装,并把结果交回 Updater |
| `MainApp` | 示例主程序,用来验证 launch ticket 和安装后完整性校验 |
| `Common` | 配置、HTTP、票据、完整性校验等公共代码 |
## 3. 当前在线更新链路
1. `Launcher` 启动后读取服务端生成的 `config/app_config.json`,并把静态配置导入当前用户的运行配置。
2. 如果配置里没有 `api_base_url`,客户端会回退到编译进 EXE 资源中的 `server_config.json`
3. `Launcher` 确保存在 `device_id`,并检查 `client_token` 是否存在。
4. `Launcher` 调用 `GET /api/v1/client/update/authorized-check`,请求头带 `X-Client-Token`
5. 如果服务端返回可用发布,`Launcher` 启动 `Updater`,并传入产品编码、渠道、目标版本和发布 ID。
6. `Updater` 调用 `GET /api/v1/client/update/manifest`,请求头继续带 `X-Client-Token`
7. `Updater` 先校验服务端返回的 `manifestSha256`,再按配置决定是否强制要求 RSA-SHA256 签名。
8. `Updater` 从 Manifest 中读取每个文件的 `downloadUrl``sizeBytes``sha256`
9. 下载请求统一带 `X-Client-Token`
10. 下载使用 `.part` 临时文件保存进度,请求失败后按网络重试策略处理。
11. 文件下载完成后,客户端按 Manifest 校验文件大小和 SHA-256。
12. 安装前校验 staging 目录,安装完成后保存 Manifest 缓存,并可在主程序启动时再次校验已安装文件。
## 4. 关键配置字段
正式客户安装包里的 `config/app_config.json` 由服务端在上传发布包 ZIP 时自动生成。常用字段如下:
| 字段 | 说明 |
| --- | --- |
| `product_code` | SimCAE Hub 后台产品目录中的产品编码,例如 `stage2-dap` |
| `app_id` | 本地应用标识,默认和产品编码一致 |
| `channel` | 发布渠道,例如 `stable` |
| `current_version` | 当前本地安装版本,例如 `1.0.0` |
| `api_base_url` | 后端 API 地址,例如 `http://192.168.1.158:18000` |
| `client_token` | Launcher/Updater 调更新接口使用的部署级令牌,不绑定某一个客户 |
| `install_root` | 相对 Launcher/Updater 所在运行目录解析的更新根目录,决定 Updater、Bootstrap 和启动校验作用在哪棵目录 |
| `main_executable` | 相对运行目录解析的业务入口程序,通常是 `MainApp.exe` 或真实软件入口 |
| `launcher_executable` | 相对运行目录解析的 Launcher 文件名,主要用于提示和保持启动链路配置一致 |
| `updater_executable` | 相对运行目录解析的 Updater 文件名,Launcher 检查到更新后会启动它 |
| `bootstrap_executable` | 相对运行目录解析的 Bootstrap 文件名,Updater 需要替换文件时会启动它 |
| `platform` | 操作系统,例如 `windows``linux` |
| `arch` | 架构,例如 `x86_64` |
| `abi` | ABI,例如 `msvc`;没有时可留空 |
| `launch_token` | Launcher 和 MainApp 之间生成一次性启动票据的本地密钥 |
| `require_manifest_signature` | 是否强制要求 Manifest 必须带签名 |
| `verify_installed_on_start` | 主程序启动时是否按 Manifest 缓存校验已安装文件 |
`config/server_config.json` 会编译进客户端资源,作为 `api_base_url` 缺失时的兜底地址,当前测试服务器地址为:
`http://192.168.1.158:18000`
如果换服务器,可以改完该文件后重新编译客户端;正式客户包通常由服务端写入 `api_base_url`,不需要把 `server_config.json` 暴露给客户。
## 5. 编译和打包 SDK
客户端编译、带 Qt 和不带 Qt 两种 SDK 打包方式、输出目录、输出 ZIP 文件名,统一维护在 [../打包成SDK.md](../打包成SDK.md)。
本文件只说明 SDK 在客户软件里的接入位置和运行逻辑,不重复维护打包命令。
SDK 包不会包含最终 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`。这些最终配置在完整客户软件包或 Qt IFW 交付包上传到 SimCAE Hub 后由服务端生成。
## 6. 客户安装包配置
接入方应把以下文件放到客户软件目录的根目录或 `bin/` 目录:
1. `Launcher`
2. `Updater`
3. `Bootstrap`
4. `MainApp` 或真实业务主程序
5. `config/` 目录,可以先为空
### 6.1 标准目录结构和路径口径
更新系统不要求必须放在客户软件根目录。它可以放在 `SimCAE/` 根目录,也可以放在 `SimCAE/bin/` 目录。关键是让客户端配置里的 `install_root` 和服务端 Manifest 文件路径使用同一套口径。
先区分三个目录概念:
| 概念 | 说明 |
| --- | --- |
| 运行目录 | Launcher、Updater、Bootstrap 所在目录,由客户端自动识别 |
| `install_root` | 相对运行目录解析的更新根目录,Updater 下载、校验、备份、回滚和 Bootstrap 替换文件都以它为范围 |
| Manifest `files[].path` | 服务端生成的安装相对路径,客户端会把它拼到 `install_root` 下面 |
目录结构一:更新系统放在软件根目录。
```text
SimCAE/
Launcher.exe
Updater.exe
Bootstrap.exe
MainApp.exe
config/
app_config.json
App/
...
```
对应配置:
```json
{
"install_root": ".",
"main_executable": "MainApp.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe"
}
```
目录结构二:更新系统和启动入口放在 `bin/`
```text
SimCAE/
bin/
Launcher.exe
Updater.exe
Bootstrap.exe
MainApp.exe
config/
app_config.json
App/
...
```
如果希望整个 `SimCAE/` 都属于更新范围,对应配置:
```json
{
"install_root": "..",
"main_executable": "MainApp.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe"
}
```
这表示 Launcher 从 `bin/` 启动 `MainApp.exe`Updater 和 Bootstrap 更新的是 `bin/` 的上一级,也就是整个 `SimCAE/`
服务端发布包路径要和客户端 `install_root` 对应:
| 客户端配置 | 服务端 Manifest 路径口径 |
| --- | --- |
| 更新系统在根目录,`install_root``.` | `MainApp.exe``App/xxx.dll` 相对 `SimCAE/` |
| 更新系统在 `bin/``install_root``..` | `bin/MainApp.exe``App/xxx.dll` 相对 `SimCAE/` |
当前管理后台“发布包”上传接口会使用上传文件名作为 `artifactName`,后端按安全文件名校验。当前稳定支持的是发布一个完整安装包或压缩包文件,或者把文件放在 `install_root` 根层级;还不是“自动解析压缩包并生成 App/bin 多文件 Manifest”的完整安装器。后续如果要让在线 Updater 直接把多个文件铺到 `App/``bin/` 等子目录,需要在现有发布包页面和 Go 后端上继续增强安全相对路径或 Manifest 文件清单生成能力。
如果后续要支持“更新系统在 `bin/`,但只校验和更新 `App/`”这类更窄的安装根目录,需要在管理后台和 Go 后端增加对应配置项,让服务端生成 `../App` 这类定制 `install_root`。当前服务端自动生成配置时只使用标准的 `.``..`
如果开启 `verify_installed_on_start`,客户端会扫描 `install_root` 下的 EXE 和 DLL。整包 Manifest 中 `required=true` 的核心文件必须存在且 SHA-256 匹配;`required=false` 的可选组件文件可以由 MaintenanceTool 管理,缺失时不会阻止启动。可选组件建议放在独立目录中,例如 `plugins/dap/`,不要和核心程序 DLL 混放。
上传完整客户软件 ZIP 时,服务端会根据发布包记录自动写入这些关键值:
```json
{
"product_code": "stage2-dap",
"channel": "stable",
"current_version": "1.1.0",
"api_base_url": "http://192.168.1.158:18000",
"client_token": "<由服务器 .env 配置>",
"install_root": ". 或 ..",
"platform": "windows",
"arch": "x86_64",
"abi": "msvc"
}
```
`install_root` 由服务端根据 Launcher 所在位置自动判断:更新系统在软件根目录时写 `.`,在 `bin/` 目录时写 `..`
## 7. 运行数据位置
Windows 运行数据目录:
`%LOCALAPPDATA%\SimCAE\HubUpdateClient\installations\<安装目录SHA256>\`
Linux 运行数据目录:
`$XDG_DATA_HOME/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
未设置 `XDG_DATA_HOME` 时通常是:
`~/.local/share/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
Manifest 缓存保存在运行数据目录下的 `update/manifest_cache`
## 8. 常见问题
1. 客户门户登录失败:检查客户门户账号是否已激活、客户是否生效、密码是否正确。
2. 检查更新没有结果:检查后台发布是否已发布、发布包是否可用、产品编码、渠道和平台参数是否一致。
3. 下载 401:检查发布包中的 `config/app_config.json` 是否由服务端生成,`client_token` 是否和服务器 `.env` 中的 `SIMCAE_CLIENT_TOKEN` 一致。
4. Manifest 校验失败:检查服务端 Manifest 是否被篡改、发布包 SHA-256 是否和实际文件一致。
5. 强制签名失败:确认 `manifest_public_key.pem` 与服务端私钥匹配;如果服务端暂未启用签名,测试环境可先把 `require_manifest_signature` 设为 `false`
6. 启动主程序失败:检查 `main_executable``install_root` 是否指向真实文件。
@@ -0,0 +1,75 @@
# SimCAE Hub 客户端编译环境和第三方依赖说明
本文说明 `update-client` 的 Qt/C++ 客户端编译环境。客户端保留 Launcher / Updater / Bootstrap 机制,依赖 Qt、CMake 和 OpenSSL。
## 1. 通用要求
| 依赖 | 要求 |
| --- | --- |
| CMake | 建议 3.20 或更高版本 |
| C++ | C++17 |
| Qt | Qt 5,至少需要 Core、Network、Gui、Widgets |
| OpenSSL | 用于 Manifest RSA-SHA256 验签 |
| 编译器 | Windows 推荐 Visual Studio 2022 x64Linux 推荐 gcc/g++ |
项目已提供 CMake Preset
| Preset | 平台 | 用途 |
| --- | --- | --- |
| `x64-debug` | Windows | Debug 编译 |
| `x64-release` | Windows | Release 编译 |
| `linux-x64-debug` | Linux | Debug 编译 |
| `linux-x64-release` | Linux | Release 编译 |
## 2. Windows 环境
建议安装:
1. Visual Studio 2022,勾选 Desktop development with C++。
2. Qt 5 x64,版本可以与当前团队环境保持一致。
3. CMake。
4. OpenSSL x64。
如果 Qt 没有加入环境变量,可以在编译前指定 `CMAKE_PREFIX_PATH``Qt5_DIR`。示例:
```powershell
$env:CMAKE_PREFIX_PATH = "C:\Qt\5.15.2\msvc2019_64"
```
如果 Qt 安装在别的位置,只改这一行。
OpenSSL 可以放在 `update-client/thirdparty/OpenSSL-Win64`,也可以在配置时通过 `SIMCAE_OPENSSL_ROOT` 指向自定义目录。
## 3. Linux 环境
Ubuntu 示例:
```bash
sudo apt update
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev qttools5-dev-tools libssl-dev
```
Linux 下通常直接使用系统 OpenSSL;如需指定自定义 OpenSSL,可用 CMake 变量配置。
## 4. 编译输出
Windows Release 可执行文件输出目录:
`update-client/out/bin/Release`
Windows CMake 构建目录:
`update-client/out/build/x64-release`
Linux Release 可执行文件输出目录以当前 CMake Preset 和构建脚本为准,SDK 打包时通过 `--source-dir` 指定。
实际打包 SDK 前,应确认 Release 输出目录中至少包含:
1. `Launcher`
2. `Updater`
3. `Bootstrap`
4. 可选的示例 `MainApp`
最终客户软件包里的 `config/app_config.json``config/manifest_public_key.pem` 由服务端在发布包上传时生成;`server_config.json` 会编译进 EXE 作为兜底地址,不需要进入 SDK 包。
SDK 打包见 `../打包成SDK.md`,客户端运行链路和接入限制见 `01-客户端接入打包部署指南.md`
+4 -8
View File
@@ -8,15 +8,15 @@ 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})
if(WIN32) if(WIN32)
@@ -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目录
+242 -72
View File
@@ -1,117 +1,287 @@
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDir>
#include <QJsonArray> #include <QJsonArray>
#include <QApplication> #include <QJsonDocument>
#include "PolicyHelper.h" #include <QSaveFile>
#include "LocalStateHelper.h" #include <QUrl>
#include <QUrlQuery>
namespace {
QString trimBaseUrl(QString value)
{
value = value.trimmed();
while (value.endsWith(QLatin1Char('/')))
value.chop(1);
return value;
}
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
{
const QString trimmed = value.trimmed();
if (!trimmed.isEmpty())
query.addQueryItem(key, trimmed);
}
QString serverMessage(const QJsonObject& response)
{
const QString msg = response.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty())
return msg;
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject object = detail.toObject();
const QString detailMsg = object.value(QStringLiteral("msg")).toString();
if (!detailMsg.isEmpty())
return detailMsg;
const QString error = object.value(QStringLiteral("error")).toString();
if (!error.isEmpty())
return error;
}
return detail.toString();
}
QJsonObject responseDataObject(const QJsonObject& response)
{
return response.value(QStringLiteral("data")).isObject()
? response.value(QStringLiteral("data")).toObject()
: response;
}
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
} // namespace
UpdateLogic::UpdateLogic(QObject* parent) UpdateLogic::UpdateLogic(QObject* parent)
: QObject(parent) : QObject(parent)
{ {
ConfigHelper& cfg = ConfigHelper::instance(); ConfigHelper& cfg = ConfigHelper::instance();
m_serverAddr = cfg.getValue("Server", "api_base_url"); m_serverAddr = trimBaseUrl(cfg.getValue("Server", "api_base_url"));
m_appId = cfg.getValue("App", "app_id"); m_appId = cfg.getValue("App", "product_code").trimmed();
m_curVer = cfg.getValue("App", "current_version"); if (m_appId.isEmpty())
m_channel = cfg.getValue("App", "channel"); m_appId = cfg.getValue("App", "app_id").trimmed();
m_curVer = cfg.getValue("App", "current_version").trimmed();
m_channel = cfg.getValue("App", "channel").trimmed();
if (m_channel.isEmpty())
m_channel = QStringLiteral("stable");
// Debug print config
qDebug() << "Read server addr:" << m_serverAddr; qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId; qDebug() << "Read product code:" << m_appId;
} }
void UpdateLogic::checkUpdate() void UpdateLogic::checkUpdate()
{ {
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) m_error.clear();
{
qDebug() << "Config incomplete, abort update check";
m_needUpdate = false; m_needUpdate = false;
m_networkOk = false;
m_lastStatusCode = 0;
m_latestVer.clear();
m_checkResp = QJsonObject();
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty())
{
m_error = QCoreApplication::translate(
"UpdateLogic",
"Update configuration is incomplete. Server, product_code and current_version are required.");
qDebug() << "Config incomplete, abort update check:" << m_error;
return;
}
if (configValue(QStringLiteral("client_token")).isEmpty())
{
m_lastStatusCode = 401;
m_error = QCoreApplication::translate(
"UpdateLogic",
"Update configuration is incomplete. client_token is required.");
m_checkResp.insert(QStringLiteral("msg"), m_error);
qDebug() << "Client token missing, abort update check:" << m_error;
return; return;
} }
QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject body;
body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/authorized-check"));
QUrlQuery query;
addQueryValue(query, QStringLiteral("productCode"), m_appId);
addQueryValue(query, QStringLiteral("currentVersion"), m_curVer);
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
addQueryValue(query, QStringLiteral("channel"), m_channel);
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
url.setQuery(query);
m_http.getRequest(url.toString(QUrl::FullyEncoded),
[this](int code, const QJsonObject& resp)
{ {
qDebug() << "Check update HTTP code:" << code; qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code; m_lastStatusCode = code;
m_checkResp = resp; m_checkResp = resp;
m_networkOk = (code == 200); m_networkOk = (code == 200);
if (code == 200) if (code != 200)
{ {
const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy())
{
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false;
m_needUpdate = false; m_needUpdate = false;
m_error = serverMessage(resp);
qDebug() << "Check update api failed:" << m_error;
return; return;
} }
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState()) const QJsonArray releases = resp.value(QStringLiteral("data")).toArray();
{ QJsonObject selected;
qDebug() << "Cannot persist online policy state"; for (const QJsonValue& value : releases) {
m_networkOk = false; const QJsonObject release = value.toObject();
const bool hasPackages = !release.value(QStringLiteral("packages")).toArray().isEmpty();
const bool hasManifest = release.value(QStringLiteral("manifest")).isObject()
|| !release.value(QStringLiteral("manifestUri")).toString().isEmpty();
if (hasPackages && hasManifest) {
selected = release;
break;
}
}
if (selected.isEmpty()) {
m_needUpdate = false; m_needUpdate = false;
if (!releases.isEmpty())
m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString();
qDebug() << "No entitled downloadable update for current client.";
return; return;
} }
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString(); m_checkResp = selected;
m_checkResp.insert(QStringLiteral("need_update"), true);
m_checkResp.insert(QStringLiteral("latest_version"), selected.value(QStringLiteral("version")).toString());
m_checkResp.insert(QStringLiteral("release_id"), selected.value(QStringLiteral("id")).toString());
m_needUpdate = true;
m_latestVer = selected.value(QStringLiteral("version")).toString();
qDebug() << "Need update:" << m_needUpdate; qDebug() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer; qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq(); qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
}); });
} }
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
{ {
QString url = m_serverAddr + "/api/v1/update/download-url"; Q_UNUSED(appId);
QJsonObject body; Q_UNUSED(channel);
body["app_id"] = appId; Q_UNUSED(ver);
body["channel"] = channel; Q_UNUSED(verId);
body["version"] = ver; qDebug() << "SimCAE Hub manifest already contains authorized package download URLs.";
body["version_id"] = verId; }
QJsonArray files;
body["files"] = files;
m_http.postRequest(url, body, [](int code, const QJsonObject& resp) bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
{ int versionId, const QString& cacheDir)
qDebug() << "\n========== File Download Url =========="; {
qDebug() << resp["files"].toArray(); Q_UNUSED(versionId);
m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty()) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"Current version manifest identity is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
.arg(appId, channel, version);
return false;
}
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
QUrlQuery query;
addQueryValue(query, QStringLiteral("productCode"), appId);
addQueryValue(query, QStringLiteral("version"), version);
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
addQueryValue(query, QStringLiteral("channel"), channel);
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
url.setQuery(query);
QJsonObject response;
int statusCode = 0;
m_http.getRequest(url.toString(QUrl::FullyEncoded),
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
}); });
if (statusCode != 200) {
const QString message = serverMessage(response);
m_error = QCoreApplication::translate(
"UpdateLogic",
"Cannot download manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. Product: %2, channel: %3, version: %4.%5")
.arg(QString::number(statusCode), appId, channel, version,
message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
return false;
}
QJsonObject wrapper = responseDataObject(response);
const QJsonObject manifest = wrapper.value(QStringLiteral("manifest")).toObject();
QString manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
if (manifestText.isEmpty())
manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
if (manifest.isEmpty() || manifestText.isEmpty()) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"The manifest response for the current local version is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
.arg(appId, channel, version);
return false;
}
const QString manifestProduct = manifest.value(QStringLiteral("productCode")).toString(
manifest.value(QStringLiteral("app_id")).toString());
if (manifestProduct != appId
|| manifest.value(QStringLiteral("channel")).toString() != channel
|| manifest.value(QStringLiteral("version")).toString() != version) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"The manifest identity does not match the current local version. Stage: cache current version manifest. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifestProduct,
manifest.value(QStringLiteral("channel")).toString(),
manifest.value(QStringLiteral("version")).toString());
return false;
}
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.")
.arg(cacheDir);
return false;
}
wrapper.insert(QStringLiteral("manifest"), manifest);
wrapper.insert(QStringLiteral("manifestText"), manifestText);
wrapper.insert(QStringLiteral("manifest_text"), manifestText);
QSaveFile file(dir.filePath(QStringLiteral("manifest_") + version + QStringLiteral(".json")));
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"Cannot save manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
.arg(file.fileName(), file.errorString());
return false;
}
qDebug() << "Current version manifest cached to" << file.fileName();
return true;
}
bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
{
Q_UNUSED(outputPath);
m_error.clear();
qDebug() << "Git tag export is not part of the current SimCAE Hub admin pages; skipped.";
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"; Q_UNUSED(deviceId);
QJsonObject body; Q_UNUSED(fromVer);
body["app_id"] = m_appId; Q_UNUSED(toVer);
body["device_id"] = deviceId; Q_UNUSED(success);
body["from_version"] = fromVer; qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
body["to_version"] = toVer;
body["result"] = success ? "success" : "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== Update Report Result ==========";
qDebug() << resp;
});
} }
+6
View File
@@ -13,14 +13,19 @@ public:
void checkUpdate(); void checkUpdate();
void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId); void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId);
void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
bool cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir);
bool refreshGitTagsFile(const QString& outputPath);
bool getNeedUpdate() const { return m_needUpdate; } bool getNeedUpdate() const { return m_needUpdate; }
QString getLatestVersion() const { return m_latestVer; } QString getLatestVersion() const { return m_latestVer; }
QJsonObject getCheckResult() const { return m_checkResp; } QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; } bool isNetworkOk() const { return m_networkOk; }
int lastStatusCode() const { return m_lastStatusCode; } int lastStatusCode() const { return m_lastStatusCode; }
QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; } QString getAppId() const { return m_appId; }
QString getProductCode() const { return m_appId; }
QString getChannel() const { return m_channel; } QString getChannel() const { return m_channel; }
private: private:
@@ -35,4 +40,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;
}; };
+163 -209
View File
@@ -1,31 +1,83 @@
#include <windows.h>
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QInputDialog> #include <QTranslator>
#include <QLineEdit> #include <QUuid>
#include <QTextCodec>
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include <QFile> namespace {
#include <QFileDialog>
#include <QDir> QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
QString productCode()
{
return configValue(QStringLiteral("product_code"),
configValue(QStringLiteral("app_id")));
}
QString ensureDeviceId()
{
ConfigHelper& config = ConfigHelper::instance();
QString deviceId = config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")).trimmed();
if (!deviceId.isEmpty())
return deviceId;
deviceId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")).trimmed();
if (deviceId.isEmpty())
deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces);
config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), deviceId);
config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), deviceId);
return deviceId;
}
QString authFailureMessage(const QJsonObject& response, const QString& fallback)
{
const QString msg = response.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty())
return msg;
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject object = detail.toObject();
const QString detailMsg = object.value(QStringLiteral("msg")).toString();
if (!detailMsg.isEmpty())
return detailMsg;
const QString error = object.value(QStringLiteral("error")).toString();
if (!error.isEmpty())
return error;
}
const QString detailText = detail.toString();
return detailText.isEmpty() ? fallback : detailText;
}
} // namespace
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("SimCAE Launcher");
QTranslator translator;
if (translator.load(QStringLiteral(":/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", "SimCAE Launcher"));
progress.setCancelButton(nullptr); progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
@@ -33,83 +85,11 @@ int main(int argc, char* argv[])
progress.show(); progress.show();
QApplication::processEvents(); QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) { const QString appDir = QApplication::applicationDirPath();
const QString text = errorText.toLower();
return text.contains("license") progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates..."));
|| errorText.contains("授权")
|| errorText.contains("过期")
|| errorText.contains("设备数量已达到上限")
|| errorText.contains("已绑定其他授权");
};
const auto clearDeviceCredential = [&]() {
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
config.setValue("Update", "device_id", QString());
};
QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
QString promptReason = reason;
while (true) {
progress.close();
bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed();
const QString prompt = promptReason.trimmed().isEmpty()
? QString("请输入%1授权 License").arg(appName.isEmpty() ? "软件" : appName)
: QString("%1\n\n请重新输入%2授权 License").arg(promptReason, appName.isEmpty() ? "软件" : appName);
licenseKey = QInputDialog::getText(
nullptr,
"输入 License",
prompt,
QLineEdit::Normal,
QString(),
&accepted
).trimmed();
if (!accepted) {
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。");
return QString();
}
if (licenseKey.isEmpty()) {
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。");
promptReason.clear();
continue;
}
if (!config.setValue("License", "license_key", licenseKey)) {
QMessageBox::critical(nullptr, "保存 License 失败",
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError()));
return QString();
}
progress.show();
progress.setLabelText("正在验证 License...");
QApplication::processEvents(); QApplication::processEvents();
return licenseKey;
}
};
while (true) {
if (licenseKey.isEmpty()) {
licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0;
}
DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"),
config.getValue("App", "app_id"),
config.getValue("App", "channel"),
licenseKey)) {
break;
}
const QString error = identity.errorString();
if (!isLicenseError(error)) {
progress.close();
QMessageBox::critical(nullptr, "设备身份验证失败", error);
return -1;
}
clearDeviceCredential();
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error));
if (licenseKey.isEmpty()) return 0;
}
UpdateLogic logic; UpdateLogic logic;
logic.checkUpdate(); logic.checkUpdate();
@@ -117,164 +97,138 @@ int main(int argc, char* argv[])
const bool networkOk = logic.isNetworkOk(); const bool networkOk = logic.isNetworkOk();
const QString latestVer = logic.getLatestVersion(); const QString latestVer = logic.getLatestVersion();
const QJsonObject response = logic.getCheckResult(); const QJsonObject response = logic.getCheckResult();
const int targetVersionId = response.value("version_id").toInt(); const QString releaseId = response.value(QStringLiteral("release_id")).toString(
const QString appId = logic.getAppId(); response.value(QStringLiteral("id")).toString());
const QString appId = logic.getProductCode();
const QString channel = logic.getChannel(); const QString channel = logic.getChannel();
const QString launchToken = config.getValue(QStringLiteral("App"), QStringLiteral("launch_token"));
const QString currentVersion = config.getValue(QStringLiteral("App"), QStringLiteral("current_version"));
const QString deviceId = ensureDeviceId();
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close();
const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message;
if (isLicenseError(displayMessage)) {
clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage));
if (!newLicense.isEmpty()) {
progress.close();
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。");
}
return 0;
}
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage);
return -1;
}
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
const auto configuredName = [&](const QString& key, const QString& fallback) { const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed(); return ConfigHelper::executableNameForCurrentPlatform(
return value.isEmpty() ? fallback : value; config.getValue(QStringLiteral("Runtime"), key), fallback);
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const QString mainExecutable = configuredName(QStringLiteral("main_executable"), QStringLiteral("MainApp"));
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); const QString updaterExecutable = configuredName(QStringLiteral("updater_executable"), QStringLiteral("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 QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)");
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
};
const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close();
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。");
return 0;
}
QString mainStartupError;
const auto startMainApp = [&]() { const auto startMainApp = [&]() {
QString ticketPath; mainStartupError.clear();
QString ticketError; if (!QFileInfo::exists(mainAppPath)) {
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, mainStartupError = QCoreApplication::translate(
launchToken, &ticketPath, &ticketError)) { "Launcher",
qDebug() << "Cannot create launch ticket:" << ticketError; "Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false; return false;
} }
QString ticketPath;
QString ticketError;
if (!TicketHelper::createTicket(appId, deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false;
}
const bool started = QProcess::startDetached(mainAppPath, const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)}); QStringList{QStringLiteral("--ticket-file=%1").arg(ticketPath)});
if (!started) QFile::remove(ticketPath); if (!started) {
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath);
}
return started; return started;
}; };
progress.setLabelText("正在验证本地运行策略..."); if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
QApplication::processEvents();
PolicyHelper policy(appDir);
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", "Update Client Rejected"),
authFailureMessage(response,
QCoreApplication::translate("Launcher", "The update client token is missing or invalid. Please download a valid package from the customer portal.")));
return -1; return -1;
} } else if (!networkOk) {
if (policy.isExpired())
{
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。"); QMessageBox::warning(nullptr,
return -1; QCoreApplication::translate("Launcher", "Update Server Unavailable"),
} QCoreApplication::translate("Launcher", "Cannot connect to the update server. The installed application will be started without downloading an update."));
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) progress.show();
{
progress.close();
QMessageBox::critical(nullptr, "当前版本不可运行",
policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion)
: policy.message());
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
progress.close();
QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。");
return -1;
}
if (state.isSystemTimeRewound())
{
progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。");
return -1;
} }
if (networkOk && needUpdate) if (networkOk && needUpdate)
{ {
progress.close(); progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; const QString prompt = QCoreApplication::translate(
const QString dialogTitle = rollbackOperation ? "版本回退" "Launcher",
: (policy.forceUpdate() ? "必须更新" : "发现新版本"); "Version %1 is available. Update now?").arg(latestVer);
const QString prompt = policy.message().isEmpty() const bool accepted = QMessageBox::question(nullptr,
? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?" QCoreApplication::translate("Launcher", "New Version Available"),
: "发现新版本 %1,是否现在更新?").arg(latestVer) prompt,
: policy.message() + QString("\n目标版本:%1").arg(latestVer); QMessageBox::Yes | QMessageBox::No,
bool accepted = true; QMessageBox::No) == QMessageBox::Yes;
if (policy.forceUpdate()) {
QMessageBox::information(nullptr, dialogTitle, prompt);
} else {
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
}
if (!accepted) { if (!accepted) {
if (!startMainApp()) { if (!startMainApp()) {
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1; return -1;
} }
return 0; return 0;
} }
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
const QStringList updaterArgs{
appId,
channel,
latestVer,
QStringLiteral("0"),
QStringLiteral("--release-id=%1").arg(releaseId)
};
if (!QFileInfo::exists(updaterPath))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater because the executable file does not exist.\nExecutable: %1\nCheck updater_executable and install_root in the generated client configuration.")
.arg(updaterPath));
return -1;
}
if (!QProcess::startDetached(updaterPath, updaterArgs)) if (!QProcess::startDetached(updaterPath, updaterArgs))
{ {
QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath)); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater process.\nExecutable: %1\nArguments: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the updater can run independently.")
.arg(updaterPath, updaterArgs.join(QStringLiteral(" "))));
return -1; return -1;
} }
return 0; return 0;
} }
if (!networkOk) { progress.setLabelText(networkOk
progress.close(); ? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", : QCoreApplication::translate("Launcher", "Offline startup. Starting..."));
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。");
return 0;
}
progress.show();
}
if (!networkOk && !policy.isOfflineAllowed())
{
progress.close();
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。");
return -1;
}
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
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"),
mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1; return -1;
} }
progress.close(); progress.close();
+3 -1
View File
@@ -4,7 +4,9 @@ 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
PRIVATE ${OPENSSL_INC} PRIVATE ${OPENSSL_INC}
+38 -41
View File
@@ -1,69 +1,66 @@
#include "MainWindow.h" #include "MainWindow.h"
#include <QApplication> #include <QApplication>
#include <QFont> #include <QCryptographicHash>
#include <QFile> #include <QFile>
#include <QFont>
#include <QLabel> #include <QLabel>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QCryptographicHash>
#include "../Common/PolicyHelper.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
static QString readDllVersion(const QString& dllPath) namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
QString readDllVersion(const QString& dllPath)
{ {
QFile file(dllPath); QFile file(dllPath);
if (!file.exists()) if (!file.exists())
return "missing"; return QStringLiteral("missing");
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
return "unreadable"; return QStringLiteral("unreadable");
QByteArray data = file.read(1024); const QByteArray data = file.read(1024);
file.close(); file.close();
return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8)); return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8));
} }
} // namespace
MainWindow::MainWindow(QWidget* parent) MainWindow::MainWindow(QWidget* parent)
: QWidget(parent) : QWidget(parent)
{ {
this->setWindowTitle("Marsco Demo MainApp"); this->setWindowTitle("SimCAE Demo MainApp");
this->resize(600, 400); this->resize(600, 400);
QString appVersion = ConfigHelper::instance().getValue("App", "current_version"); const QString appVersion = configValue(QStringLiteral("current_version"), QStringLiteral("unknown"));
if (appVersion.isEmpty()) const QString product = configValue(QStringLiteral("product_code"),
appVersion = "unknown"; configValue(QStringLiteral("app_id"), QStringLiteral("unknown")));
const QString channel = configValue(QStringLiteral("channel"), QStringLiteral("stable"));
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); const QString apiBaseUrl = configValue(QStringLiteral("api_base_url"), QStringLiteral("not configured"));
const QString tokenState = configValue(QStringLiteral("client_token")).isEmpty()
PolicyHelper policy(QApplication::applicationDirPath()); ? QStringLiteral("missing")
QString policyResult; : QStringLiteral("configured");
if (!policy.loadPolicy("config/version_policy.dat")) const QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
{
policyResult = "policy missing";
}
else if (!policy.isValid())
{
policyResult = "policy invalid";
}
else if (!policy.isVersionAllowed(appVersion))
{
policyResult = "version disabled";
}
else if (policy.isExpired())
{
policyResult = "policy expired";
}
else
{
policyResult = "policy ok";
}
QVBoxLayout* layout = new QVBoxLayout(this); QVBoxLayout* layout = new QVBoxLayout(this);
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3") QLabel* label = new QLabel(QString(
.arg(appVersion) "Software Running Successfully\n"
.arg(dllVersion) "Product: %1\n"
.arg(policyResult)); "Version: %2\n"
"Channel: %3\n"
"Server: %4\n"
"Update Client Token: %5\n"
"DLL Version: %6")
.arg(product, appVersion, channel, apiBaseUrl, tokenState, dllVersion));
QFont font = label->font(); QFont font = label->font();
font.setPointSize(14); font.setPointSize(13);
label->setFont(font); label->setFont(font);
label->setAlignment(Qt::AlignCenter); label->setAlignment(Qt::AlignCenter);
+54 -69
View File
@@ -1,47 +1,75 @@
#include <Windows.h>
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QTextCodec>
#include <QMessageBox>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QMessageBox>
#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/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h" #include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h" #include "../Common/TicketHelper.h"
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
QString productCode()
{
return configValue(QStringLiteral("product_code"),
configValue(QStringLiteral("app_id")));
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
} // namespace
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(QStringLiteral(":/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(QStringLiteral("Runtime"), QStringLiteral("launcher_executable")).trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, QStringLiteral("Launcher"));
QString ticketFilePath; QString ticketFilePath;
QString healthFilePath; QString healthFilePath;
for (int i = 1; i < argc; ++i) for (int i = 1; i < argc; ++i)
{ {
const QString arg(argv[i]); const QString arg(argv[i]);
if (arg.startsWith("--ticket-file=")) if (arg.startsWith(QStringLiteral("--ticket-file=")))
ticketFilePath = arg.mid(QString("--ticket-file=").size()); ticketFilePath = arg.mid(QStringLiteral("--ticket-file=").size());
else if (arg.startsWith("--health-file=")) else if (arg.startsWith(QStringLiteral("--health-file=")))
healthFilePath = arg.mid(QString("--health-file=").size()); healthFilePath = arg.mid(QStringLiteral("--health-file=").size());
} }
QString ticketError; QString ticketError;
if (ticketFilePath.isEmpty() if (ticketFilePath.isEmpty()
|| !TicketHelper::consumeAndVerify(ticketFilePath, || !TicketHelper::consumeAndVerify(ticketFilePath,
config.getValue("App", "app_id"), config.getValue("Update", "device_id"), productCode(), config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"), config.getValue(QStringLiteral("App"), QStringLiteral("current_version")),
config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")),
&ticketError)) &ticketError))
{ {
QMessageBox::critical(nullptr, "Startup Restriction", QMessageBox::critical(nullptr, "Startup Restriction",
@@ -50,65 +78,19 @@ int main(int argc, char* argv[])
return -1; return -1;
} }
QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot(); const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir); if (configFlag(QStringLiteral("verify_installed_on_start"))) {
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
{
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
return -1;
}
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
return -1;
}
if (policy.isExpired())
{
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
return -1;
}
if (state.isSystemTimeRewound())
{
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
return -1;
}
IntegrityHelper integrity(installRoot); IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion( if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"), productCode(),
config.getValue("App", "current_version"))) config.getValue(QStringLiteral("App"), QStringLiteral("channel")),
config.getValue(QStringLiteral("App"), QStringLiteral("current_version"))))
{ {
QMessageBox::critical(nullptr, "Integrity Check Failed", QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Application files failed signed Manifest verification:\n%1") QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1")
.arg(integrity.errorString())); .arg(integrity.errorString()));
return -1; return -1;
} }
MainWindow w;
w.show();
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState())
{
QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString()));
return -1;
} }
if (!healthFilePath.isEmpty()) if (!healthFilePath.isEmpty())
@@ -125,6 +107,9 @@ int main(int argc, char* argv[])
} }
} }
MainWindow w;
w.show();
qDebug() << "Main program MainApp is running normally"; qDebug() << "Main program MainApp is running normally";
return a.exec(); return a.exec();
} }
-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`
+10 -9
View File
@@ -1,11 +1,10 @@
# 强制所有编译、设计时生成均使用x64,禁止Win32 if(WIN32 AND MSVC)
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁Win32") # 强制所有编译、设计时生成均使用x64,禁Win32
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64) set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
# 关闭VS自动Win32设计时预生成 set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF) # 关闭VS自动Win32设计时预生成
# 清除32位Strawberry Perl路径干扰 set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include") endif()
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,7 +13,9 @@ 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})
if(WIN32) if(WIN32)
+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");
+392 -162
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>
@@ -14,8 +15,11 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <QApplication> #include <QApplication>
#include <QUrl>
#include <QUrlQuery>
#include <algorithm> #include <algorithm>
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/pem.h> #include <openssl/pem.h>
@@ -24,33 +28,142 @@
#include <openssl/err.h> #include <openssl/err.h>
#endif #endif
namespace {
QString trimBaseUrl(QString value)
{
value = value.trimmed();
while (value.endsWith(QLatin1Char('/')))
value.chop(1);
return value;
}
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
{
const QString trimmed = value.trimmed();
if (!trimmed.isEmpty())
query.addQueryItem(key, trimmed);
}
QString serverDetailMessage(const QJsonObject& response)
{
const QString msg = response.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty())
return msg;
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject obj = detail.toObject();
const QString msg = obj.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty()) return msg;
const QString error = obj.value(QStringLiteral("error")).toString();
if (!error.isEmpty()) return error;
}
return detail.toString();
}
qint64 manifestFileSize(const QJsonObject& file)
{
if (file.contains(QStringLiteral("sizeBytes")))
return file.value(QStringLiteral("sizeBytes")).toVariant().toLongLong();
if (file.contains(QStringLiteral("size")))
return file.value(QStringLiteral("size")).toVariant().toLongLong();
return -1;
}
QString absoluteDownloadUrl(const QString& baseUrl, const QString& downloadUrl)
{
const QString trimmed = downloadUrl.trimmed();
if (trimmed.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)
|| trimmed.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive))
return trimmed;
if (trimmed.startsWith(QLatin1Char('/')))
return trimBaseUrl(baseUrl) + trimmed;
return trimBaseUrl(baseUrl) + QLatin1Char('/') + trimmed;
}
}
UpdaterLogic::UpdaterLogic(QObject* parent) UpdaterLogic::UpdaterLogic(QObject* parent)
: QObject(parent) : QObject(parent)
{ {
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url"); m_serverAddr = trimBaseUrl(ConfigHelper::instance().getValue("Server", "api_base_url"));
} }
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, const QString& releaseId)
{ {
QString url = m_serverAddr + "/api/v1/update/manifest"; // Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
QJsonObject body; // Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
body["app_id"] = appId; m_error.clear();
body["channel"] = channel; Q_UNUSED(versionId);
body["version"] = targetVer; m_manifestSha256.clear();
body["version_id"] = versionId; m_manifestSignature.clear();
m_manifestSignatureAlg.clear();
m_manifestKeyId.clear();
m_manifestSigned = false;
m_fileItems.clear();
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
QUrlQuery query;
addQueryValue(query, QStringLiteral("releaseId"), releaseId);
addQueryValue(query, QStringLiteral("productCode"), appId);
addQueryValue(query, QStringLiteral("version"), targetVer);
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
addQueryValue(query, QStringLiteral("channel"), channel);
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
url.setQuery(query);
m_http.getRequest(url.toString(QUrl::FullyEncoded),
[this, appId, channel, targetVer, releaseId](int code, const QJsonObject& resp)
{ {
qDebug() << "Manifest API returned code:" << code; qDebug() << "Manifest API returned code:" << code;
m_manifest = QJsonObject(); m_manifest = QJsonObject();
m_manifestText.clear(); m_manifestText.clear();
m_manifestSha256.clear();
m_manifestSignature.clear();
m_manifestSignatureAlg.clear();
m_manifestKeyId.clear();
m_manifestSigned = false;
if (code == 200) if (code == 200)
{ {
if (resp.contains("manifest_text") && resp.contains("manifest")) const QJsonObject envelope = resp.value(QStringLiteral("data")).isObject()
? resp.value(QStringLiteral("data")).toObject()
: resp;
QString manifestText = envelope.value(QStringLiteral("manifestText")).toString();
if (manifestText.isEmpty())
manifestText = envelope.value(QStringLiteral("manifest_text")).toString();
const QJsonObject manifest = envelope.value(QStringLiteral("manifest")).toObject();
if (!manifestText.isEmpty() && !manifest.isEmpty())
{ {
m_manifestText = resp["manifest_text"].toString(); m_manifestText = manifestText;
m_manifest = resp["manifest"].toObject(); m_manifest = manifest;
m_manifestSha256 = envelope.value(QStringLiteral("manifestSha256")).toString(
envelope.value(QStringLiteral("manifest_sha256")).toString());
m_manifestSignature = envelope.value(QStringLiteral("signature")).toString(
m_manifest.value(QStringLiteral("signature")).toString());
m_manifestSignatureAlg = envelope.value(QStringLiteral("signatureAlg")).toString(
envelope.value(QStringLiteral("signature_alg")).toString());
m_manifestKeyId = envelope.value(QStringLiteral("keyId")).toString(
envelope.value(QStringLiteral("key_id")).toString());
m_manifestSigned = envelope.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
qDebug() << "Received manifest version:" << m_manifest.value("version").toString(); qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
m_fileItems.clear(); m_fileItems.clear();
QJsonArray files = m_manifest.value("files").toArray(); QJsonArray files = m_manifest.value("files").toArray();
@@ -58,21 +171,30 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
{ {
QJsonObject fileObj = fileItem.toObject(); QJsonObject fileObj = fileItem.toObject();
FileDownloadItem fi; FileDownloadItem fi;
fi.path = fileObj.value("path").toString(); fi.path = fileObj.value(QStringLiteral("path")).toString();
fi.sha256 = fileObj.value("sha256").toString(); fi.sha256 = fileObj.value(QStringLiteral("sha256")).toString();
fi.size = fileObj.value("size").toVariant().toLongLong(); fi.size = manifestFileSize(fileObj);
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL fi.url = absoluteDownloadUrl(m_serverAddr,
fileObj.value(QStringLiteral("downloadUrl")).toString());
m_fileItems.append(fi); m_fileItems.append(fi);
} }
} }
else else
{ {
qDebug() << "Manifest response missing fields"; qDebug() << "Manifest response missing fields";
m_error = QCoreApplication::translate("UpdaterLogic",
"Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4.")
.arg(appId, channel, targetVer, releaseId);
} }
} }
else else
{ {
qDebug() << "Failed to get manifest"; qDebug() << "Failed to get manifest";
const QString detail = serverDetailMessage(resp);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. Product: %2, channel: %3, version: %4, release id: %5.%6")
.arg(QString::number(code), appId, channel, targetVer, releaseId,
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
} }
emit fetchUrlFinished(); emit fetchUrlFinished();
}); });
@@ -85,12 +207,17 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
Q_UNUSED(signatureBase64); Q_UNUSED(signatureBase64);
Q_UNUSED(publicKeyPath); Q_UNUSED(publicKeyPath);
qDebug() << "OpenSSL not available, cannot verify manifest signature"; qDebug() << "OpenSSL not available, cannot verify manifest signature";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot verify manifest signature because OpenSSL support is unavailable. Stage: manifest signature verification.");
return false; return false;
#else #else
QFile keyFile(publicKeyPath); QFile keyFile(publicKeyPath);
if (!keyFile.open(QIODevice::ReadOnly)) if (!keyFile.open(QIODevice::ReadOnly))
{ {
qDebug() << "Cannot open public key file:" << publicKeyPath; qDebug() << "Cannot open public key file:" << publicKeyPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open manifest public key. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false; return false;
} }
@@ -101,6 +228,9 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!bio) if (!bio)
{ {
qDebug() << "BIO_new_mem_buf failed"; qDebug() << "BIO_new_mem_buf failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot parse manifest public key buffer. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false; return false;
} }
@@ -109,6 +239,9 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!pkey) if (!pkey)
{ {
qDebug() << "PEM_read_bio_PUBKEY failed"; qDebug() << "PEM_read_bio_PUBKEY failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest public key is invalid. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false; return false;
} }
@@ -118,6 +251,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
{ {
EVP_PKEY_free(pkey); EVP_PKEY_free(pkey);
qDebug() << "EVP_MD_CTX_new failed"; qDebug() << "EVP_MD_CTX_new failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create OpenSSL verification context. Stage: manifest signature verification.");
return false; return false;
} }
@@ -139,6 +274,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!ok) if (!ok)
{ {
qDebug() << "Manifest signature verification failed"; qDebug() << "Manifest signature verification failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest RSA signature is invalid. Stage: manifest signature verification. This usually means the manifest was not signed by the matching server private key, the client public key is wrong, or the manifest content was changed.");
} }
return ok; return ok;
#endif #endif
@@ -146,17 +283,40 @@ 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";
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
return false; return false;
} }
QString signature = m_manifest.value("signature").toString(); if (!m_manifestSha256.isEmpty()) {
if (signature.isEmpty()) const QString actualSha = QString::fromLatin1(
{ QCryptographicHash::hash(m_manifestText.toUtf8(), QCryptographicHash::Sha256).toHex());
qDebug() << "Manifest signature empty"; if (actualSha.compare(m_manifestSha256, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest SHA-256 does not match the server envelope. Stage: manifest digest verification.\nExpected SHA-256: %1\nActual SHA-256: %2")
.arg(m_manifestSha256, actualSha);
return false; return false;
} }
}
const bool requireSignature = configFlag(QStringLiteral("require_manifest_signature"));
const QString signature = m_manifestSignature.trimmed();
if (signature.isEmpty() || !m_manifestSigned)
{
if (requireSignature) {
qDebug() << "Manifest signature empty";
m_error = QCoreApplication::translate("UpdaterLogic",
"The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification.");
return false;
}
qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled.";
m_error.clear();
return true;
}
QString path = publicKeyPath; QString path = publicKeyPath;
if (!QFile::exists(path)) if (!QFile::exists(path))
@@ -168,50 +328,96 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
{ {
if (m_manifest.isEmpty()) // 启动后的完整性检查依赖本地 Manifest 缓存。
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot save manifest cache because the target manifest is empty. Stage: save target manifest cache.");
return false; return false;
}
QDir dir(cacheDir); QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create manifest cache directory. Stage: save target manifest cache. Directory: %1.")
.arg(cacheDir);
return false; return false;
}
QString version = m_manifest.value("version").toString(); QString version = m_manifest.value("version").toString();
QString filePath = cacheDir + "/manifest_" + version + ".json"; QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath); QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot write manifest cache file. Stage: save target manifest cache. File: %1. Error: %2.")
.arg(filePath, file.errorString());
return false; return false;
}
QJsonObject wrapper; QJsonObject wrapper;
wrapper["manifest"] = m_manifest; wrapper["manifest"] = m_manifest;
wrapper["manifestText"] = m_manifestText;
wrapper["manifest_text"] = m_manifestText; wrapper["manifest_text"] = m_manifestText;
wrapper["manifestSha256"] = m_manifestSha256;
wrapper["signature"] = m_manifestSignature;
wrapper["signatureAlg"] = m_manifestSignatureAlg;
wrapper["keyId"] = m_manifestKeyId;
wrapper["signed"] = m_manifestSigned;
QJsonDocument doc(wrapper); QJsonDocument doc(wrapper);
file.write(doc.toJson(QJsonDocument::Indented)); file.write(doc.toJson(QJsonDocument::Indented));
file.close(); file.close();
qDebug() << "Manifest cached to" << filePath; qDebug() << "Manifest cached to" << filePath;
m_error.clear();
return true; return true;
} }
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version) bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
{ {
m_error.clear();
QString filePath = cacheDir + "/manifest_" + version + ".json"; QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath); QFile file(filePath);
if (!file.exists() || !file.open(QIODevice::ReadOnly)) if (!file.exists() || !file.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot read cached signed manifest. Stage: read local manifest cache. Version: %1. File: %2. This cache is created after a version is installed successfully.")
.arg(version, filePath);
return false; return false;
}
QByteArray raw = file.readAll(); QByteArray raw = file.readAll();
file.close(); file.close();
QJsonDocument doc = QJsonDocument::fromJson(raw); QJsonDocument doc = QJsonDocument::fromJson(raw);
if (!doc.isObject()) if (!doc.isObject()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached signed manifest is not valid JSON. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath);
return false; return false;
}
QJsonObject wrapper = doc.object(); QJsonObject wrapper = doc.object();
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) if (!wrapper.contains("manifest")
|| (!wrapper.contains("manifestText") && !wrapper.contains("manifest_text"))) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath);
return false; return false;
}
m_manifest = wrapper["manifest"].toObject(); m_manifest = wrapper["manifest"].toObject();
m_manifestText = wrapper["manifest_text"].toString(); m_manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
if (m_manifestText.isEmpty())
m_manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
m_manifestSha256 = wrapper.value(QStringLiteral("manifestSha256")).toString(
wrapper.value(QStringLiteral("manifest_sha256")).toString());
m_manifestSignature = wrapper.value(QStringLiteral("signature")).toString(
m_manifest.value(QStringLiteral("signature")).toString());
m_manifestSignatureAlg = wrapper.value(QStringLiteral("signatureAlg")).toString(
wrapper.value(QStringLiteral("signature_alg")).toString());
m_manifestKeyId = wrapper.value(QStringLiteral("keyId")).toString(
wrapper.value(QStringLiteral("key_id")).toString());
m_manifestSigned = wrapper.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
qDebug() << "Loaded cached manifest" << version; qDebug() << "Loaded cached manifest" << version;
m_error.clear();
return true; return true;
} }
@@ -263,33 +469,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded()); if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
} }
QSet<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
for (const QString& path : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + path);
}
QStringList obsolete; QStringList obsolete;
QSet<QString> seen; QSet<QString> seen;
for (const QJsonValue& value : oldManifest.value("files").toArray()) { for (const QJsonValue& value : oldManifest.value("files").toArray()) {
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); const QJsonObject item = value.toObject();
if (item.contains(QStringLiteral("required"))
&& !item.value(QStringLiteral("required")).toBool(true)) {
continue;
}
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
const QString folded = path.toCaseFolded(); const QString folded = path.toCaseFolded();
if (!isSafeRelativePath(path) || protectedPaths.contains(folded) if (!isSafeRelativePath(path) || isRuntimeProtectedPath(path)
|| newPaths.contains(folded) || seen.contains(folded)) || newPaths.contains(folded) || seen.contains(folded))
continue; continue;
seen.insert(folded); seen.insert(folded);
@@ -301,8 +491,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
{ {
if (m_manifest.isEmpty()) m_error.clear();
const QString stage = installedDir.isEmpty()
? QCoreApplication::translate("UpdaterLogic", "installed version verification")
: QCoreApplication::translate("UpdaterLogic", "downloaded/staged file verification");
const QString version = m_manifest.value(QStringLiteral("version")).toString();
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available. Stage: %1. The updater cannot know which files and hashes should be verified.")
.arg(stage);
return false; return false;
}
const QJsonArray files = m_manifest.value("files").toArray(); const QJsonArray files = m_manifest.value("files").toArray();
for (const auto& item : files) for (const auto& item : files)
@@ -310,8 +509,12 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
const QJsonObject fileObject = item.toObject(); const QJsonObject fileObject = item.toObject();
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString()); const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
const QString expectedSha = fileObject.value("sha256").toString(); const QString expectedSha = fileObject.value("sha256").toString();
if (!isSafeRelativePath(path)) if (!isSafeRelativePath(path)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest contains an unsafe file path. Stage: %1. Version: %2. Path: %3.")
.arg(stage, version, path);
return false; return false;
}
if (isRuntimeProtectedPath(path)) { if (isRuntimeProtectedPath(path)) {
qDebug() << "Runtime-protected manifest entry ignored:" << path; qDebug() << "Runtime-protected manifest entry ignored:" << path;
continue; continue;
@@ -324,16 +527,32 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
if (!QFile::exists(fullPath)) if (!QFile::exists(fullPath))
{ {
qDebug() << "Manifest file missing from staging and installation:" << path; qDebug() << "Manifest file missing from staging and installation:" << path;
m_error = QCoreApplication::translate("UpdaterLogic",
"A required file is missing. Stage: %1. Version: %2. Manifest path: %3. Checked path: %4. If this is a downloaded update, the file was not downloaded or staged correctly; if this is startup verification, the installed file may have been deleted.")
.arg(stage, version, path, fullPath);
return false;
}
const qint64 expectedSize = manifestFileSize(fileObject);
if (expectedSize >= 0 && QFileInfo(fullPath).size() != expectedSize)
{
m_error = QCoreApplication::translate("UpdaterLogic",
"File size does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected size: %5 bytes\nActual size: %6 bytes")
.arg(stage, version, path, fullPath,
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
return false; return false;
} }
const QString actualSha = calcLocalFileSha256(fullPath); const QString actualSha = calcLocalFileSha256(fullPath);
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
{ {
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha; qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
m_error = QCoreApplication::translate("UpdaterLogic",
"File SHA-256 does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected SHA-256: %5\nActual SHA-256: %6\nIf this happens during download, clear the update cache and retry. If this happens during startup or after installation, the local file differs from the published version.")
.arg(stage, version, path, fullPath, expectedSha, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
return false; return false;
} }
} }
qDebug() << "Full manifest validation passed using staging plus installed files"; qDebug() << "Full manifest validation passed using staging plus installed files";
m_error.clear();
return true; return true;
} }
@@ -341,86 +560,70 @@ 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; } m_manifestSha256 = packageMeta.value("manifest_sha256").toString();
if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; } m_manifestSignature = wrapper.value("manifest_signature").toString();
m_manifestSignatureAlg = wrapper.value("signature_alg").toString("RSA-SHA256");
m_manifestKeyId = wrapper.value("key_id").toString();
m_manifestSigned = !m_manifestSignature.isEmpty();
const QString manifestProduct = manifest.value("productCode").toString(manifest.value("app_id").toString());
const QString packageProduct = packageMeta.value("productCode").toString(packageMeta.value("app_id").toString());
if (manifestProduct != packageProduct || 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 = 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 (isRuntimeProtectedPath(path)) continue;
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;
} }
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{ {
QString url = m_serverAddr + "/api/v1/update/download-url"; Q_UNUSED(appId);
QJsonObject body; Q_UNUSED(channel);
body["app_id"] = appId; Q_UNUSED(targetVer);
body["channel"] = channel; Q_UNUSED(versionId);
body["version"] = targetVer; m_error.clear();
body["version_id"] = versionId; if (m_fileItems.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
QJsonArray emptyFiles; "The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads.");
body["files"] = emptyFiles; } else {
qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest.";
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
{
qDebug() << "Download URL API returned code:" << code;
m_fileItems.clear();
if (code == 200)
{
QJsonArray fileArr = resp["files"].toArray();
for (auto item : fileArr)
{
QJsonObject obj = item.toObject();
FileDownloadItem fi;
fi.path = obj["path"].toString();
fi.url = obj["url"].toString();
fi.sha256 = obj["sha256"].toString();
fi.size = obj["size"].toVariant().toLongLong();
m_fileItems.append(fi);
qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
}
}
else
{
qDebug() << "Failed to get download URL";
} }
emit fetchUrlFinished(); emit fetchUrlFinished();
});
} }
@@ -444,10 +647,18 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
const QString& resumePartPath) const QString& resumePartPath)
{ {
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath; const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
if (!QDir().mkpath(QFileInfo(partPath).path())) return false; const QString displayPath = m_currentDownloadPath.isEmpty() ? savePath : m_currentDownloadPath;
if (!QDir().mkpath(QFileInfo(partPath).path())) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create partial download directory. Stage: download file. File: %1. Partial file: %2.")
.arg(displayPath, partPath);
return false;
}
if (QFile::exists(savePath) if (QFile::exists(savePath)
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) && calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) {
m_error.clear();
return true; return true;
}
QFile::remove(savePath); QFile::remove(savePath);
for (int attempt = 0; attempt < 4; ++attempt) for (int attempt = 0; attempt < 4; ++attempt)
@@ -463,8 +674,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{ {
QFile::remove(savePath); QFile::remove(savePath);
return QFile::rename(partPath, savePath); if (QFile::rename(partPath, savePath)) {
m_error.clear();
return true;
} }
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
}
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached partial file has the expected size but wrong SHA-256. Stage: resume download. File: %1.\nExpected SHA-256: %2\nActual SHA-256: %3\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, expectSha256, actualSha);
QFile::remove(partPath); QFile::remove(partPath);
existingSize = 0; existingSize = 0;
} }
@@ -475,13 +697,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (!partFile.open(mode)) if (!partFile.open(mode))
{ {
qDebug() << "Cannot open partial download:" << partPath; qDebug() << "Cannot open partial download:" << partPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open partial download file. Stage: download file. File: %1. Partial file: %2. Error: %3.")
.arg(displayPath, partPath, partFile.errorString());
return false; return false;
} }
QNetworkAccessManager manager; QNetworkAccessManager manager;
manager.setProxy(QNetworkProxy::NoProxy); manager.setProxy(QNetworkProxy::NoProxy);
QNetworkRequest request(url); QNetworkRequest request{QUrl(url)};
request.setTransferTimeout(60000); request.setTransferTimeout(60000);
const QString clientToken = configValue(QStringLiteral("client_token"));
if (!clientToken.isEmpty())
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
if (existingSize > 0) if (existingSize > 0)
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-"); request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
@@ -514,7 +742,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);
} }
@@ -528,19 +756,37 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (QFile::rename(partPath, savePath)) if (QFile::rename(partPath, savePath))
{ {
qDebug() << "Download completed/resumed & sha pass:" << savePath; qDebug() << "Download completed/resumed & sha pass:" << savePath;
m_error.clear();
return true; return true;
} }
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
} }
else if (expectedSize >= 0 && actualSize >= expectedSize) else if (expectedSize >= 0 && actualSize >= expectedSize)
{ {
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath; qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file does not match the signed manifest. Stage: download file. File: %1. HTTP status: %2.\nExpected size: %3 bytes\nActual size: %4 bytes\nExpected SHA-256: %5\nActual SHA-256: %6\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize),
expectSha256, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
QFile::remove(partPath); QFile::remove(partPath);
} }
else {
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file is incomplete. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes, current size: %4 bytes.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize));
}
} }
else else
{ {
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1 qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size(); << savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
m_error = QCoreApplication::translate("UpdaterLogic",
"Download request failed. Stage: download file. File: %1. Attempt: %2/4. HTTP status: %3. Network error: %4. Partial file: %5.")
.arg(displayPath, QString::number(attempt + 1), QString::number(httpStatus), networkError, partPath);
} }
if (attempt < 3) if (attempt < 3)
@@ -555,42 +801,23 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
} }
} }
} }
if (m_error.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"File download failed after retries. Stage: download file. File: %1.")
.arg(displayPath);
}
return false; return false;
} }
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{ {
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded(); return UpdatePathPolicy::isFullUpdateProtectedPath(
QSet<QString> protectedPaths{ path, ConfigHelper::instance().runtimeRelativePath());
QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(normalized);
} }
bool UpdaterLogic::isSafeRelativePath(const QString& path) const bool UpdaterLogic::isSafeRelativePath(const QString& path) const
{ {
const QString normalized = QDir::fromNativeSeparators(path); return UpdatePathPolicy::isSafeRelativePath(path);
const QString clean = QDir::cleanPath(normalized);
return !clean.isEmpty()
&& !QDir::isAbsolutePath(clean)
&& clean != ".."
&& !clean.startsWith("../")
&& !clean.contains(":");
} }
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir, qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
@@ -621,18 +848,29 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir) bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
{ {
m_error.clear();
if (m_fileItems.isEmpty()) if (m_fileItems.isEmpty())
{ {
m_downloadAllOk = false; m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"The target version manifest contains no downloadable files. Stage: prepare downloads.");
return false; return false;
} }
QDir stagingDir(tempDir); QDir stagingDir(tempDir);
if (!FileHelper::createDir(tempDir)) if (!FileHelper::createDir(tempDir)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create update staging directory. Stage: prepare downloads. Directory: %1.")
.arg(tempDir);
return false; return false;
}
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
if (!FileHelper::createDir(resumeCacheDir)) if (!FileHelper::createDir(resumeCacheDir)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create download cache directory. Stage: prepare downloads. Directory: %1.")
.arg(resumeCacheDir);
return false; return false;
}
QSet<QString> activePartialNames; QSet<QString> activePartialNames;
for (const auto& item : m_fileItems) for (const auto& item : m_fileItems)
activePartialNames.insert(item.sha256.toLower() + ".part"); activePartialNames.insert(item.sha256.toLower() + ".part");
@@ -662,6 +900,9 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
{ {
qDebug() << "Unsafe relative path in manifest:" << fi.path; qDebug() << "Unsafe relative path in manifest:" << fi.path;
m_downloadAllOk = false; m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest contains an unsafe file path. Stage: prepare file download. Path: %1.")
.arg(fi.path);
return false; return false;
} }
@@ -684,6 +925,9 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
{ {
qDebug() << "Cannot create staging subdirectory:" << parentDir; qDebug() << "Cannot create staging subdirectory:" << parentDir;
m_downloadAllOk = false; m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create staging subdirectory. Stage: prepare file download. File: %1. Directory: %2.")
.arg(relativePath, parentDir);
return false; return false;
} }
@@ -705,19 +949,18 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
} }
m_downloadAllOk = true; m_downloadAllOk = true;
m_error.clear();
return true; return true;
} }
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
const QString& version, bool success) const QString& version, bool success)
{ {
QJsonArray files; Q_UNUSED(appId);
for (const FileDownloadItem& item : m_fileItems) Q_UNUSED(channel);
files.append(QJsonObject{{"path", item.path}, {"size", item.size}}); Q_UNUSED(version);
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version}, Q_UNUSED(success);
{"result", success ? "success" : "fail"}, {"files", files}}; qDebug() << "Download result report is not part of the current SimCAE Hub API; skipped.";
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
} }
void UpdaterLogic::reportResult(const QString& deviceId, void UpdaterLogic::reportResult(const QString& deviceId,
@@ -725,24 +968,11 @@ void UpdaterLogic::reportResult(const QString& deviceId,
const QString& toVer, const QString& toVer,
bool success) bool success)
{ {
QString url = m_serverAddr + "/api/v1/update/report"; Q_UNUSED(deviceId);
Q_UNUSED(fromVer);
QJsonObject body; Q_UNUSED(toVer);
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id"); Q_UNUSED(success);
body["device_id"] = deviceId; qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
body["from_version"] = fromVer;
body["to_version"] = toVer;
if (success)
body["result"] = "success";
else
body["result"] = "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
Q_UNUSED(resp);
qDebug() << "Update result report returned code:" << code;
});
} }
QList<FileDownloadItem> UpdaterLogic::getFileList() const QList<FileDownloadItem> UpdaterLogic::getFileList() const
+9 -1
View File
@@ -24,13 +24,15 @@ class UpdaterLogic : public QObject
public: public:
explicit UpdaterLogic(QObject* parent = nullptr); explicit UpdaterLogic(QObject* parent = nullptr);
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId); void getManifest(const QString& appId, const QString& channel, const QString& targetVer,
int versionId, const QString& releaseId = QString());
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const; bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const; bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version); bool loadManifestCache(const QString& cacheDir, const QString& version);
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString()); bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
QString offlineError() const { return m_offlineError; } QString offlineError() const { return m_offlineError; }
QString errorString() const { return m_error; }
void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId); void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
@@ -62,6 +64,11 @@ private:
QString m_serverAddr; QString m_serverAddr;
QJsonObject m_manifest; QJsonObject m_manifest;
QString m_manifestText; QString m_manifestText;
QString m_manifestSha256;
QString m_manifestSignature;
QString m_manifestSignatureAlg;
QString m_manifestKeyId;
bool m_manifestSigned = false;
QList<FileDownloadItem> m_fileItems; QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false; bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0; qint64 m_downloadTotalBytes = 0;
@@ -69,5 +76,6 @@ private:
qint64 m_sessionDownloadedBytes = 0; qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath; QString m_currentDownloadPath;
QString m_offlineError; QString m_offlineError;
mutable QString m_error;
QElapsedTimer m_downloadTimer; QElapsedTimer m_downloadTimer;
}; };
+202 -77
View File
@@ -1,18 +1,18 @@
#include <windows.h> #include <QApplication>
#include <QApplication>
#include <QCoreApplication> #include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QSaveFile> #include <QSaveFile>
#include <QStorageInfo> #include <QStorageInfo>
#include <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,21 +23,29 @@
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("SimCAE 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;
QString appId; QString appId;
QString channel; QString channel;
QString targetVersion; QString targetVersion;
QString releaseId;
int targetVersionId = 0; int targetVersionId = 0;
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 +55,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();
@@ -57,6 +67,8 @@ int main(int argc, char* argv[])
const QString arg = argv[i]; const QString arg = argv[i];
if (arg.startsWith("--bootstrap-resume=")) if (arg.startsWith("--bootstrap-resume="))
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
else if (arg.startsWith("--release-id="))
releaseId = arg.mid(QString("--release-id=").size());
} }
const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath(); const QString runtimeDir = QApplication::applicationDirPath();
@@ -65,8 +77,16 @@ int main(int argc, char* argv[])
const QString targetDir = config.installRoot(); const QString targetDir = config.installRoot();
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")) { QString configuredProductCode = config.getValue("App", "product_code").trimmed();
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); if (configuredProductCode.isEmpty())
configuredProductCode = config.getValue("App", "app_id").trimmed();
QString configuredChannel = config.getValue("App", "channel").trimmed();
if (configuredChannel.isEmpty())
configuredChannel = QStringLiteral("stable");
if (appId != configuredProductCode || channel != configuredChannel) {
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 +94,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 +115,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 +161,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),
@@ -153,13 +182,18 @@ int main(int argc, char* argv[])
QMessageBox::critical(nullptr, title, message); QMessageBox::critical(nullptr, title, message);
return -1; return -1;
}; };
const auto configuredName = [&](const QString& key, const QString& fallback) { const auto withDetails = [](const QString& message, const QString& details) {
const QString value = config.getValue("Runtime", key).trimmed(); return details.trimmed().isEmpty()
return value.isEmpty() ? fallback : value; ? message
: message + QCoreApplication::translate("Updater", "\n\nDetails:\n%1").arg(details);
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); return ConfigHelper::executableNameForCurrentPlatform(
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe"); config.getValue("Runtime", key), fallback);
};
const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
bool timeoutOk = false; 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;
@@ -167,19 +201,38 @@ int main(int argc, char* argv[])
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable); const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable); const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
const QString launchToken = config.getValue("App", "launch_token"); const QString launchToken = config.getValue("App", "launch_token");
QString mainStartupError;
const auto launchMainApp = [&](const QString& healthFile = QString()) { const auto launchMainApp = [&](const QString& healthFile = QString()) {
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
const QString launchVersion = config.getValue("App", "current_version"); const QString launchVersion = config.getValue("App", "current_version");
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken, if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
&ticketPath, &ticketError)) { &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false; return false;
} }
QStringList args{QString("--ticket-file=%1").arg(ticketPath)}; QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
const bool started = QProcess::startDetached(mainAppPath, args); const bool started = QProcess::startDetached(mainAppPath, args);
if (!started) QFile::remove(ticketPath); if (!started) {
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nHealth file: %3\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath, healthFile.isEmpty() ? QCoreApplication::translate("Updater", "<not used>") : healthFile);
}
return started; return started;
}; };
const auto bootstrapPlanFile = [&]() { const auto bootstrapPlanFile = [&]() {
@@ -196,7 +249,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 +258,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 +267,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 +281,55 @@ 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"),
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
logic.errorString()));
} else if (offlinePackagePath.isEmpty()) { } else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId);
} }
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"); withDetails(QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
logic.errorString()),
"manifest_signature_invalid");
} }
QStringList obsoletePaths; QStringList obsoletePaths;
if (!resumingFromBootstrap && fromVersion != targetVersion) { if (!resumingFromBootstrap && fromVersion != targetVersion) {
@@ -274,45 +345,67 @@ 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"); withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
logic.errorString()),
"manifest_cache_failed");
} }
if (!resumingFromBootstrap) { 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"),
withDetails(QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
logic.errorString()),
"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"),
withDetails(QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network, update cache, or server release files and try again."),
logic.errorString()),
"download_failed");
} }
logic.reportDownloadResult(appId, channel, targetVersion, true); 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"),
withDetails(QCoreApplication::translate("Updater", "The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files."),
logic.errorString()),
"staging_verify_failed");
QStringList changedPaths; QStringList changedPaths;
QDir stagingRoot(stagingDir); QDir stagingRoot(stagingDir);
@@ -324,24 +417,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 +453,58 @@ 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"),
withDetails(QCoreApplication::translate("Updater", "New version files failed verification after installation. The updater will roll back to the previous version."),
logic.errorString()));
for (const QString& path : transaction.obsoletePaths()) { 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"),
mainStartupError.isEmpty()
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
: mainStartupError);
QElapsedTimer healthTimer; QElapsedTimer healthTimer;
healthTimer.start(); healthTimer.start();
@@ -394,17 +513,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;
} }
-22
View File
@@ -1,22 +0,0 @@
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"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"
}
+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:18000"
}
+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>
File diff suppressed because it is too large Load Diff
-2943
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
SimCAE Hub 客户端脚本说明
==========================
本目录保存 Qt/C++ 客户端更新链路的辅助脚本。客户端仍然由
Launcher、Updater、Bootstrap 和业务主程序组成,服务端接口使用当前
SimCAE Hub 的 Go API。
脚本列表:
1. package-sdk.ps1
在 Windows 上生成给业务软件接入用的客户端更新运行时包。
2. package-client.ps1
Windows 本地调试用的客户包脚本,需要手工提供 app_config.json。
新流程建议上传完整软件 ZIP 到 SimCAE Hub,由服务端生成最终配置。
3. install-sdk.ps1
把客户端更新运行时复制到业务软件 Release 目录。
4. package-sdk.sh
在 Linux 上生成客户端更新运行时包,输出 tar.gz。
5. package-client.sh
Linux 本地调试用的客户包脚本,需要手工提供 app_config.json。
SDK 打包命令、两种打包模式、参数含义和输出位置,统一看:
../打包成SDK.md
生成 SDK 后,把 Launcher、Updater、Bootstrap 和必要运行库放进业务软件
根目录或 bin 目录,再把完整软件目录压缩上传到 SimCAE Hub 管理后台的
发布包页面。服务端会生成 config/app_config.json,并在启用 Manifest 签名
时生成 config/manifest_public_key.pem。
+2 -16
View File
@@ -4,8 +4,6 @@ param(
[string]$ReleaseDir = (Get-Location).Path, [string]$ReleaseDir = (Get-Location).Path,
[switch]$OverwriteConfig,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
@@ -15,11 +13,8 @@ $sdk = (Resolve-Path $SdkRoot).Path
$release = (Resolve-Path $ReleaseDir).Path $release = (Resolve-Path $ReleaseDir).Path
$binDir = Join-Path $sdk "bin" $binDir = Join-Path $sdk "bin"
$configDir = Join-Path $sdk "config"
$appConfig = Join-Path $configDir "app_config.json"
$publicKey = Join-Path $configDir "manifest_public_key.pem"
foreach ($path in @($binDir, $appConfig, $publicKey)) { foreach ($path in @($binDir)) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
throw "SDK file is missing: $path" throw "SDK file is missing: $path"
} }
@@ -61,14 +56,5 @@ Get-ChildItem $binDir -Force | Where-Object {
$targetConfigDir = Join-Path $release "config" $targetConfigDir = Join-Path $release "config"
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
$targetAppConfig = Join-Path $targetConfigDir "app_config.json"
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
Copy-Item $appConfig $targetAppConfig -Force
} else {
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
}
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force
Write-Host "SDK files installed to: $release" Write-Host "SDK files installed to: $release"
Write-Host "Next: edit config/app_config.json, then start Launcher.exe." Write-Host "Next: package the whole application directory and upload it in SimCAE Hub. The server will generate config/app_config.json and config/manifest_public_key.pem when needed."
@@ -1,13 +1,25 @@
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 = "",
[switch]$SkipManifestCheck
) )
$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/SimCAEUpdateClient"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/SimCAEUpdateClient.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,9 +49,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 "SimCAE\HubUpdateClient\installations\$installId\update\manifest_cache\$ManifestName"
}
$requiredFields = @( $requiredFields = @(
"app_id", "channel", "api_base_url", "current_version", "product_code", "channel", "current_version", "launch_token",
"client_token", "launch_token", "license_key",
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
) )
foreach ($field in $requiredFields) { foreach ($field in $requiredFields) {
@@ -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"
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName" $runtimeDirAbsolute = if ([string]::IsNullOrWhiteSpace($runtimeDirRelative)) {
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar) $source
if (-not (Test-Path $sourceManifest)) { } else {
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName" Join-Path $source ($runtimeDirRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
if (Test-Path $legacySourceManifest) {
$sourceManifest = $legacySourceManifest
}
} }
if (-not (Test-Path $sourceManifest)) { $sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." $manifestCandidates = @(
(Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
(Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
(Join-Path $source "update/manifest_cache/$manifestName")
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $SkipManifestCheck -and (-not $sourceManifest -or -not (Test-Path $sourceManifest))) {
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
throw "Missing Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging, or pass -SkipManifestCheck for a first-time test package. Searched paths:$([Environment]::NewLine)$searched"
} }
if (Test-Path $OutputDir) { if (Test-Path $OutputDir) {
@@ -126,8 +161,10 @@ Copy-Item $config $outputConfigPath -Force
} }
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) $manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
New-Item $manifestDir -ItemType Directory -Force | Out-Null if ($sourceManifest -and (Test-Path $sourceManifest)) {
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force New-Item $manifestDir -ItemType Directory -Force | Out-Null
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
}
$zipParent = Split-Path $ZipFile -Parent $zipParent = Split-Path $ZipFile -Parent
New-Item $zipParent -ItemType Directory -Force | Out-Null New-Item $zipParent -ItemType Directory -Force | Out-Null
+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/SimCAEUpdateClient-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEUpdateClient-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/SimCAEUpdateClient-linux
--archive FILE Output tar.gz. Default: ./dist/SimCAEUpdateClient-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/SimCAE/HubUpdateClient/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 product_code channel current_version launch_token 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 Manifest cache for current version: $MANIFEST_NAME." >&2
echo "Complete online update/verification for this version before packaging, or pass --skip-manifest-check for a first-time test package. 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"
+61 -25
View File
@@ -1,17 +1,26 @@
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",
[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/SimCAEHubUpdateClientSDK"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/SimCAEHubUpdateClientSDK.zip"
}
$source = (Resolve-Path $SourceDir).Path $source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") $requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
foreach ($name in $requiredFiles) { foreach ($name in $requiredFiles) {
@@ -21,16 +30,6 @@ foreach ($name in $requiredFiles) {
} }
} }
$publicKeyCandidates = @(
(Join-Path $source "config/manifest_public_key.pem"),
(Join-Path $source "manifest_public_key.pem"),
(Join-Path $PSScriptRoot "config/manifest_public_key.pem")
)
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) {
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key."
}
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
$_.Extension -in @('.pdb', '.ilk') $_.Extension -in @('.pdb', '.ilk')
@@ -45,7 +44,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" }
@@ -70,6 +71,11 @@ function Test-IsQtRuntimeFile {
return ( return (
$Item.Name -match '^Qt5.*\.dll$' -or $Item.Name -match '^Qt5.*\.dll$' -or
$Item.Name -match '^vc_redist.*\.exe$' -or
$Item.Name -match '^vcredist.*\.exe$' -or
$Item.Name -match '^vcruntime.*\.dll$' -or
$Item.Name -match '^msvcp.*\.dll$' -or
$Item.Name -match '^concrt.*\.dll$' -or
$Item.Name -in @( $Item.Name -in @(
"libEGL.dll", "libEGL.dll",
"libGLESv2.dll", "libGLESv2.dll",
@@ -83,16 +89,41 @@ Get-ChildItem $source -Force | Where-Object {
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_) $_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
} | Copy-Item -Destination $binDir -Recurse -Force } | Copy-Item -Destination $binDir -Recurse -Force
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force $commonSourceDir = Join-Path $RepoRoot "Common"
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force $commonSourceFiles = @(
"ConfigHelper.h",
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { "ConfigHelper.cpp",
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" "IntegrityHelper.h",
} | Sort-Object Name | Select-Object -First 1 "IntegrityHelper.cpp",
if (-not $wordGuideSource) { "TicketHelper.h",
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." "TicketHelper.cpp",
"UpdatePathPolicy.h",
"UpdatePathPolicy.cpp"
)
foreach ($commonFile in $commonSourceFiles) {
$commonPath = Join-Path $commonSourceDir $commonFile
if (-not (Test-Path $commonPath)) {
throw "SDK Common integration source is missing: $commonPath"
}
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
}
$docsSourceDir = Join-Path $RepoRoot "Docs"
if (Test-Path $docsSourceDir) {
Copy-Item (Join-Path $docsSourceDir "*") $docsDir -Recurse -Force
}
$wordGuideSource = @($RepoRoot, $docsSourceDir) |
Where-Object { Test-Path $_ } |
ForEach-Object { Get-ChildItem $_ -File -Filter "*.docx" } |
Where-Object { $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" } |
Sort-Object Name |
Select-Object -First 1
if ($wordGuideSource) {
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
} else {
Write-Warning "SDK Word guide is missing. Continue packaging with Markdown documents in Docs/."
} }
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force Copy-Item (Join-Path $PSScriptRoot "package-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 +135,11 @@ 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
contains_qt_runtime = [bool]$IncludeQtRuntime
contains_final_config = $false
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
+159
View File
@@ -0,0 +1,159 @@
#!/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/SimCAEHubUpdateClientSDK-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-linux.tar.gz"
SDK_VERSION="0.1.0"
INCLUDE_DEMO_MAIN_APP=0
INCLUDE_QT_RUNTIME=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/SimCAEHubUpdateClientSDK-linux
--archive FILE SDK tar.gz path. Default: ./dist/SimCAEHubUpdateClientSDK-linux.tar.gz
--sdk-version VERSION SDK version. Default: 0.1.0
--include-demo-mainapp Include MainApp demo executable in SDK bin.
--include-qt-runtime Include Qt runtime files from the Release output directory.
-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 ;;
--include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
--include-qt-runtime) INCLUDE_QT_RUNTIME=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
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
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"
is_qt_runtime_item() {
local base="$1"
case "$base" in
bearer|iconengines|imageformats|platforms|styles|translations)
return 0
;;
libQt5*.so*|libEGL.so*|libGLESv2.so*|libqxcb.so*|libxcb*.so*|libstdc++.so*|libgcc_s.so*|libssl.so*|libcrypto.so*|opengl32sw.dll|d3dcompiler_47.dll)
return 0
;;
*)
return 1
;;
esac
}
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
;;
*)
if [[ "$INCLUDE_QT_RUNTIME" -eq 0 ]] && is_qt_runtime_item "$base"; then
continue
fi
cp -a "$item" "$OUTPUT_DIR/bin/"
;;
esac
done
shopt -u dotglob nullglob
for common_file in ConfigHelper.h ConfigHelper.cpp IntegrityHelper.h IntegrityHelper.cpp TicketHelper.h TicketHelper.cpp UpdatePathPolicy.h UpdatePathPolicy.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),
"contains_qt_runtime": $([[ "$INCLUDE_QT_RUNTIME" -eq 1 ]] && echo true || echo false),
"contains_final_config": false,
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
"word_guide_included": $WORD_GUIDE_INCLUDED,
"integration_sources": [
"ConfigHelper.h",
"ConfigHelper.cpp",
"IntegrityHelper.h",
"IntegrityHelper.cpp",
"TicketHelper.h",
"TicketHelper.cpp",
"UpdatePathPolicy.h",
"UpdatePathPolicy.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"
+117
View File
@@ -0,0 +1,117 @@
# Hub 更新客户端打包成 SDK
本文只说明如何从 `SIMCAE/update-client` 生成给 SIMCAE 开发者使用的 SDK 包。SIMCAE 如何拿这个 SDK 打客户安装器和交付包,见项目根目录的 [客户端部署.md](../../客户端部署.md)。
## 一、SDK 包含什么
SDK 用来把 Hub 更新客户端接入 SIMCAE 安装包。
| 内容 | 作用 |
| --- | --- |
| `Launcher.exe` | 客户日常启动入口,检查整包更新并启动主程序 |
| `Updater.exe` | 拉取 Manifest、下载发布包、校验 SHA-256、准备安装 |
| `Bootstrap.exe` | 替换运行中文件时接管安装 |
| Qt 运行库 | 可选,给没有单独 Qt 运行环境的接入方使用 |
| 文档 | 说明客户端目录结构、配置字段和接入方式 |
SDK 不包含最终客户配置文件,例如 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`。这些文件由服务端在上传客户软件包或 Qt IFW 交付包时生成或注入。
## 二、编译 Release
先进入 SIMCAE 仓库下的 `update-client` 目录。如果当前已经在 SIMCAE 仓库根目录:
```powershell
cd .\update-client
```
然后执行:
```powershell
cmake --preset x64-release
cmake --build --preset x64-release
```
编译完成后,Release 产物通常位于 `out/bin/Release`
检查核心程序:
```powershell
Test-Path .\out\bin\Release\Launcher.exe
Test-Path .\out\bin\Release\Updater.exe
Test-Path .\out\bin\Release\Bootstrap.exe
```
预期都返回 `True`
## 三、打包不带 Qt 运行库的 SDK
适用于接入方已经有 Qt 运行环境,或希望自己控制 Qt DLL 的情况。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\SimCAEHubUpdateClientSDK `
-ZipFile .\dist\SimCAEHubUpdateClientSDK.zip `
-SdkVersion 0.1.0
```
输出:
| 输出 | 说明 |
| --- | --- |
| `dist\SimCAEHubUpdateClientSDK` | SDK 展开目录 |
| `dist\SimCAEHubUpdateClientSDK.zip` | 可交给 SIMCAE 开发者的 SDK 压缩包 |
## 四、打包带 Qt 运行库的 SDK
适用于接入方不想单独准备 Qt DLL,或者希望拿到后能直接放进安装包。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\SimCAEHubUpdateClientSDK-with-qt `
-ZipFile .\dist\SimCAEHubUpdateClientSDK-with-qt.zip `
-SdkVersion 0.1.0 `
-IncludeQtRuntime
```
输出:
| 输出 | 说明 |
| --- | --- |
| `dist\SimCAEHubUpdateClientSDK-with-qt` | 带 Qt 运行库的 SDK 展开目录 |
| `dist\SimCAEHubUpdateClientSDK-with-qt.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
## 五、打包后检查
```powershell
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Launcher.exe
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Updater.exe
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Bootstrap.exe
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt.zip
```
预期都返回 `True`
## 六、不要提交的内容
`SIMCAE/update-client/.gitignore` 已忽略这些本地内容:
- `thirdparty/`
- `out/`
- `dist/`
- `*.exe`
- `*.dll`
- `*.zip`
- `config/app_config.json`
- `config/client_identity.dat`
- `config/local_state.json`
- `config/version_policy.dat`
提交前看一下:
```powershell
git status --short
```
不要把本地依赖、编译产物、SDK ZIP、客户配置和运行状态提交进仓库。