10 Commits

55 changed files with 8034 additions and 6025 deletions
+6
View File
@@ -0,0 +1,6 @@
root = true
[*]
charset = utf-8-bom
trim_trailing_whitespace = true
insert_final_newline = true
+65 -62
View File
@@ -1,70 +1,73 @@
# Operating systems and editors # Operating systems and editors
.DS_Store .DS_Store
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
.vscode/ .vscode/
.idea/ .idea/
.vs/ .vs/
*.suo *.suo
*.user *.user
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
*.bak *.bak
*.swp *.swp
*.log *.log
*~ *~
# Private requirement / handover documents # Private requirement / handover documents
*.pdf *.pdf
*.doc *.doc
*.docx *.docx
private/
# C/C++ generated artifacts pdf_requirements.txt
*.obj
*.o # C/C++ generated artifacts
*.pdb *.obj
*.ilk *.o
*.idb *.pdb
*.tlog *.ilk
*.lastbuildstate *.idb
*.exp *.tlog
*.lib *.lastbuildstate
*.dll *.exp
*.exe *.lib
*.dll
# Build outputs *.exe
out/
build/ # Build outputs
build-*/ out/
cmake-build-*/ build/
.cmake/ build-*/
CMakeFiles/ cmake-build-*/
CMakeCache.txt .cmake/
CMakeSettings.json CMakeFiles/
CMakeUserPresets.json CMakeCache.txt
Testing/ CMakeSettings.json
CMakeUserPresets.json
Testing/
# Third-party/business binary drops and generated packages # Third-party/business binary drops and generated packages
thirdparty/
App/ App/
dist/ dist/
*.zip *.zip
*.tar *.tar
*.tar.gz *.tar.gz
*.tgz *.tgz
*.7z *.7z
*.rar *.rar
# Local runtime state and credentials # Local runtime state and credentials
config/app_config.json config/app_config.json
config/client_identity.dat config/client_identity.dat
config/local_state.json config/local_state.json
config/version_policy.dat config/version_policy.dat
config/*.local.json config/*.local.json
config/*private*.pem config/*private*.pem
config/*private*.key config/*private*.key
update/ update/
update_temp/ update_temp/
+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()
+156 -143
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; static QString cleanRelativePath(const QString& value)
result.push_back(ch); {
QString path = QDir::fromNativeSeparators(value.trimmed());
if (path.isEmpty())
return {};
path = QDir::cleanPath(path);
if (path == "." || path == ".." || path.startsWith("../")
|| path.contains("/../") || QDir::isAbsolutePath(path)
|| path.contains(':')) {
return {};
} }
result.append(backslashes * 2, L'\\'); return path;
result.push_back(L'\"');
return result;
} }
static std::wstring fromUtf8(const std::string& value) static QString joinPath(const QString& root, const QString& relativePath)
{ {
if (value.empty()) return {}; return QDir(root).filePath(relativePath);
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0);
if (size <= 0) return {};
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), result.data(), size);
return result;
} }
static bool safeRelative(const fs::path& path) static bool removeWithRetry(const QString& path)
{ {
if (path.empty() || path.is_absolute() || path.has_root_name()) return false; // Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
for (const auto& part : path) // Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
if (part == L"..") return false;
return true;
}
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
{
std::error_code ec;
fs::create_directories(destination.parent_path(), ec);
for (int i = 0; i < 100; ++i) { for (int i = 0; i < 100; ++i) {
ec.clear(); if (!QFileInfo::exists(path))
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec); return true;
if (!ec) return true; if (QFile::remove(path))
std::this_thread::sleep_for(std::chrono::milliseconds(100)); return true;
QThread::msleep(100);
}
return !QFileInfo::exists(path);
}
static bool copyWithRetry(const QString& source, const QString& destination)
{
QDir().mkpath(QFileInfo(destination).absolutePath());
for (int i = 0; i < 100; ++i) {
QFile::remove(destination);
if (QFile::copy(source, destination))
return true;
QThread::msleep(100);
} }
return false; 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");
QThread::msleep(500);
QVector<PlanItem> items;
QVector<QString> paths;
if (!readPlan(planFile, &items, &paths)) {
showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid."));
return 3;
} }
struct PlanItem { wchar_t operation; fs::path relative; }; bool success = true;
std::ifstream input(planFile, std::ios::binary);
std::vector<PlanItem> items;
std::vector<fs::path> paths;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR);
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 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;
+113 -22
View File
@@ -1,44 +1,128 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(ClientAll LANGUAGES C CXX) project(ClientAll LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(MSVC) if(MSVC)
# Source files should be saved as UTF-8 with BOM; compile source and
# execution charsets as UTF-8 instead of setting encodings in code.
add_compile_options(/utf-8) add_compile_options(/utf-8)
endif() endif()
# Qt全局配置
# Qt global config. Qt is intentionally not hard-coded here; configure it with
# CMake-recognized environment variables such as CMAKE_PREFIX_PATH or Qt5_DIR.
set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CMAKE_INCLUDE_CURRENT_DIR ON)
set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON) set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON) set(CMAKE_AUTORCC ON)
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets) find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
# ========== OpenSSL 手动配置(完全抛弃find_package ==========
set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64") get_target_property(QT_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION)
set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include") get_filename_component(QT_BIN_DIR "${QT_QMAKE_EXECUTABLE}" DIRECTORY)
set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd") find_program(QT_LRELEASE_EXECUTABLE NAMES lrelease lrelease.exe HINTS "${QT_BIN_DIR}" REQUIRED)
set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD") find_program(QT_LUPDATE_EXECUTABLE NAMES lupdate lupdate.exe HINTS "${QT_BIN_DIR}" REQUIRED)
# 全局宏,所有子项目统一启用OpenSSL
set(TRANSLATION_TS "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.ts")
set(TRANSLATION_QM "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.qm")
set(TRANSLATION_SCAN_DIRS
"${CMAKE_SOURCE_DIR}/Common"
"${CMAKE_SOURCE_DIR}/Bootstrap"
"${CMAKE_SOURCE_DIR}/Launcher"
"${CMAKE_SOURCE_DIR}/Updater"
"${CMAKE_SOURCE_DIR}/MainApp"
)
add_custom_command(
OUTPUT "${TRANSLATION_QM}"
COMMAND "${QT_LRELEASE_EXECUTABLE}" "${TRANSLATION_TS}" -qm "${TRANSLATION_QM}"
DEPENDS "${TRANSLATION_TS}"
COMMENT "Compile Qt translation: update-client_zh_CN.qm"
VERBATIM
)
add_custom_target(update_client_translations ALL
DEPENDS "${TRANSLATION_QM}"
)
add_custom_target(update_client_lupdate
COMMAND "${QT_LUPDATE_EXECUTABLE}"
${TRANSLATION_SCAN_DIRS}
-extensions cpp,h
-no-obsolete
-ts "${TRANSLATION_TS}"
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
COMMENT "Update Qt translation source: update-client_zh_CN.ts"
VERBATIM
)
# Local-only third-party dependencies. The thirdparty directory is ignored by Git.
# Windows can use thirdparty/OpenSSL-Win64. Linux normally uses system OpenSSL.
set(THIRDPARTY_DIR "${CMAKE_SOURCE_DIR}/thirdparty" CACHE PATH "Local third-party dependency root")
if(WIN32)
set(SIMCAE_OPENSSL_ROOT "${THIRDPARTY_DIR}/OpenSSL-Win64" CACHE PATH "OpenSSL Win64 root")
if(NOT EXISTS "${SIMCAE_OPENSSL_ROOT}/include/openssl")
message(FATAL_ERROR
"OpenSSL not found: ${SIMCAE_OPENSSL_ROOT}\n"
"Copy OpenSSL-Win64 to thirdparty/OpenSSL-Win64, or configure with "
"-DSIMCAE_OPENSSL_ROOT=<OpenSSL-Win64 root>."
)
endif()
set(OPENSSL_INC "${SIMCAE_OPENSSL_ROOT}/include")
set(OPENSSL_LIB_DEBUG "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MDd")
set(OPENSSL_LIB_RELEASE "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MD")
foreach(OPENSSL_LIB_DIR IN ITEMS "${OPENSSL_LIB_DEBUG}" "${OPENSSL_LIB_RELEASE}")
if(NOT EXISTS "${OPENSSL_LIB_DIR}")
message(FATAL_ERROR "OpenSSL library directory not found: ${OPENSSL_LIB_DIR}")
endif()
endforeach()
set(SIMCAE_OPENSSL_LINK_LIBRARIES
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libssl.lib>
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libcrypto.lib>
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libssl.lib>
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libcrypto.lib>
)
else()
find_package(OpenSSL REQUIRED)
set(OPENSSL_INC "${OPENSSL_INCLUDE_DIR}")
set(SIMCAE_OPENSSL_LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto)
endif()
add_compile_definitions(HAVE_OPENSSL=1) add_compile_definitions(HAVE_OPENSSL=1)
# ==========================================================
# 统一输出目录 # 统一输出目录。Visual Studio Release 实际输出到 out/bin/ReleaseLinux 单独输出到 out/linux/bin。
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) if(UNIX AND NOT APPLE)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out/linux)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) else()
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库 set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out)
endif()
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
# Bootstrap 使用 Qt 实现跨平台更新交接,避免直接依赖 Win32 API。
add_subdirectory(Bootstrap) add_subdirectory(Bootstrap)
# 先编译公共库 # 先编译公共库
add_subdirectory(Common) add_subdirectory(Common)
# 再编译三个业务程序 # 再编译三个业务程序
add_subdirectory(Launcher) add_subdirectory(Launcher)
add_subdirectory(Updater) add_subdirectory(Updater)
add_subdirectory(MainApp) add_subdirectory(MainApp)
# 默认启动项目 # 默认启动项目
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# Copy client.ini and config directory to output bin folder
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") # Copy config templates and static resources to output bin folder.
if(UNIX AND NOT APPLE AND EXISTS "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json")
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json")
else()
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
endif()
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config") set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem") set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移 # app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config") file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
@@ -46,10 +130,11 @@ if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${IN
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
endif() endif()
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat) foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}") if(EXISTS "${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY) configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
endif() endif()
endforeach() endforeach()
# 编译阶段同步静态配置资源 # 编译阶段同步静态配置资源
add_custom_target(copy_client_resources ALL add_custom_target(copy_client_resources ALL
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER} COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}
@@ -61,10 +146,16 @@ add_custom_target(copy_client_resources ALL
add_dependencies(Launcher copy_client_resources) add_dependencies(Launcher copy_client_resources)
add_dependencies(Updater copy_client_resources) add_dependencies(Updater copy_client_resources)
add_dependencies(MainApp copy_client_resources) add_dependencies(MainApp copy_client_resources)
add_dependencies(Launcher update_client_translations)
add_dependencies(Updater update_client_translations)
add_dependencies(MainApp update_client_translations)
add_dependencies(Bootstrap update_client_translations)
# Install rules for packaging # Install rules for packaging
if(EXISTS ${CONFIG_SOURCE_DIR}) if(EXISTS "${APP_CONFIG_SOURCE_FILE}")
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config) install(FILES "${APP_CONFIG_SOURCE_FILE}" DESTINATION bin/config RENAME app_config.json)
endif()
if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
endif() endif()
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE})
install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin)
endif()
+99
View File
@@ -0,0 +1,99 @@
{
"version": 3,
"configurePresets": [
{
"name": "windows-x64-base",
"displayName": "Windows x64 Base",
"description": "Windows x64 build with Visual Studio 2022.",
"hidden": true,
"generator": "Visual Studio 17 2022",
"architecture": {
"value": "x64",
"strategy": "set"
},
"binaryDir": "${sourceDir}/out/build/${presetName}",
"installDir": "${sourceDir}/out/install/${presetName}",
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Windows"
}
},
{
"name": "linux-x64-base",
"displayName": "Linux x64 Base",
"description": "Linux x64 build with system Qt and OpenSSL.",
"hidden": true,
"generator": "Unix Makefiles",
"binaryDir": "${sourceDir}/out/build/${presetName}",
"installDir": "${sourceDir}/out/install/${presetName}",
"condition": {
"type": "equals",
"lhs": "${hostSystemName}",
"rhs": "Linux"
}
},
{
"name": "x64-debug",
"displayName": "x64 Debug",
"description": "Debug build for Windows x64.",
"inherits": "windows-x64-base",
"cacheVariables": {
"CMAKE_CONFIGURATION_TYPES": "Debug",
"CMAKE_INSTALL_CONFIG_NAME": "Debug"
}
},
{
"name": "x64-release",
"displayName": "x64 Release",
"description": "Release build for Windows x64.",
"inherits": "windows-x64-base",
"cacheVariables": {
"CMAKE_CONFIGURATION_TYPES": "Release",
"CMAKE_INSTALL_CONFIG_NAME": "Release"
}
},
{
"name": "linux-x64-debug",
"displayName": "Linux x64 Debug",
"description": "Debug build for Linux x64.",
"inherits": "linux-x64-base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "linux-x64-release",
"displayName": "Linux x64 Release",
"description": "Release build for Linux x64.",
"inherits": "linux-x64-base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release"
}
}
],
"buildPresets": [
{
"name": "x64-debug",
"displayName": "x64 Debug",
"configurePreset": "x64-debug",
"configuration": "Debug"
},
{
"name": "x64-release",
"displayName": "x64 Release",
"configurePreset": "x64-release",
"configuration": "Release"
},
{
"name": "linux-x64-debug",
"displayName": "Linux x64 Debug",
"configurePreset": "linux-x64-debug"
},
{
"name": "linux-x64-release",
"displayName": "Linux x64 Release",
"configurePreset": "linux-x64-release"
}
]
}
+31 -33
View File
@@ -1,38 +1,36 @@
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
HttpHelper.cpp HttpHelper.cpp
FileHelper.h FileHelper.h
FileHelper.cpp FileHelper.cpp
ConfigHelper.h ConfigHelper.h
ConfigHelper.cpp ConfigHelper.cpp
PolicyHelper.h PolicyHelper.h
PolicyHelper.cpp PolicyHelper.cpp
LocalStateHelper.h LocalStateHelper.h
LocalStateHelper.cpp LocalStateHelper.cpp
TicketHelper.h TicketHelper.h
TicketHelper.cpp TicketHelper.cpp
IntegrityHelper.h IntegrityHelper.h
IntegrityHelper.cpp IntegrityHelper.cpp
DeviceIdentityHelper.h DeviceIdentityHelper.h
DeviceIdentityHelper.cpp DeviceIdentityHelper.cpp
) )
add_library(Common STATIC ${SRC}) add_library(Common STATIC ${SRC})
# Common编译自身需要OpenSSL头文件 # Common编译自身需要OpenSSL头文件
target_include_directories(Common target_include_directories(Common
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
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()
+804 -122
View File
@@ -1,13 +1,327 @@
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QByteArray>
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue>
#include <QMessageBox>
#include <QSaveFile> #include <QSaveFile>
#include <QSettings> #include <QSettings>
#include <QStandardPaths>
#include <QStringList>
#include <QDebug> #include <QDebug>
#include <string>
#ifdef Q_OS_WIN
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#include <shellapi.h>
#endif
namespace
{
const QString kElevatedConfigSetFlag = QStringLiteral("--simcae-config-set");
const QString kElevatedFileWriteFlag = QStringLiteral("--simcae-file-write");
const QString kElevatedFileRemoveFlag = QStringLiteral("--simcae-file-remove");
const QString kConfigPathPrefix = QStringLiteral("--config-path-b64=");
const QString kConfigKeyPrefix = QStringLiteral("--config-key-b64=");
const QString kConfigValuePrefix = QStringLiteral("--config-value-b64=");
const QString kFilePathPrefix = QStringLiteral("--file-path-b64=");
const QString kFileDataPrefix = QStringLiteral("--file-data-b64=");
const QString kRegistryOrganization = QStringLiteral("Marsco");
const QString kRegistryApplication = QStringLiteral("UpdateClientSDK");
const QString kRegistryInstallationsGroup = QStringLiteral("installations");
const QString kRegistryConfigGroup = QStringLiteral("config");
const QString kRegistryMetaGroup = QStringLiteral("_meta");
const QString kRegistrySourceHashKey = QStringLiteral("source_sha256");
const QString kRegistrySourcePathKey = QStringLiteral("source_path");
const QString kRegistryRuntimeRootKey = QStringLiteral("runtime_root");
const QString kRegistryImportedAtKey = QStringLiteral("imported_at_utc");
const QString kEmbeddedServerConfigPath = QStringLiteral(":/simcae/server_config.json");
const QString kApiBaseUrlKey = QStringLiteral("api_base_url");
// 需要提权写入时,子进程参数统一用 Base64Url 编码。
// 这样可以避免 Windows 路径、中文、空格或换行在 ShellExecute 参数传递中被截断或误解析。
QString encodeArgument(const QString& value)
{
return QString::fromLatin1(value.toUtf8().toBase64(
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
QString encodeBytes(const QByteArray& value)
{
return QString::fromLatin1(value.toBase64(
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
QString decodeArgument(const QString& value)
{
return QString::fromUtf8(QByteArray::fromBase64(value.toLatin1(),
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
}
QByteArray decodeBytes(const QString& value)
{
return QByteArray::fromBase64(value.toLatin1(),
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
}
QString findArgumentValue(const QStringList& arguments, const QString& prefix)
{
for (const QString& argument : arguments)
if (argument.startsWith(prefix))
return argument.mid(prefix.size());
return QString();
}
bool writeBytesToFile(const QString& path, const QByteArray& bytes, QString* errorMessage)
{
QDir dir(QFileInfo(path).path());
if (!dir.exists() && !dir.mkpath("."))
{
if (errorMessage)
*errorMessage = QString("Cannot create directory: %1").arg(dir.path());
return false;
}
QSaveFile output(path);
if (!output.open(QIODevice::WriteOnly))
{
if (errorMessage)
*errorMessage = QString("Cannot open file for writing: %1").arg(output.errorString());
return false;
}
if (output.write(bytes) != bytes.size())
{
if (errorMessage)
*errorMessage = QString("Cannot write full file: %1").arg(output.errorString());
output.cancelWriting();
return false;
}
if (!output.commit())
{
if (errorMessage)
*errorMessage = QString("Cannot commit file: %1").arg(output.errorString());
return false;
}
if (errorMessage)
errorMessage->clear();
return true;
}
bool removeFile(const QString& path, QString* errorMessage)
{
if (!QFile::exists(path))
{
if (errorMessage)
errorMessage->clear();
return true;
}
if (QFile::remove(path))
{
if (errorMessage)
errorMessage->clear();
return true;
}
if (errorMessage)
*errorMessage = QString("Cannot remove file: %1").arg(path);
return false;
}
bool writeConfigValueToFile(const QString& configPath, const QString& key,
const QString& value, QString* errorMessage)
{
QFile input(configPath);
QJsonObject config;
if (input.exists())
{
if (!input.open(QIODevice::ReadOnly))
{
if (errorMessage)
*errorMessage = QString("Cannot open config for reading: %1").arg(input.errorString());
return false;
}
QJsonParseError parseError;
const QByteArray raw = input.readAll();
input.close();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
if (errorMessage)
*errorMessage = QString("Invalid config JSON: %1").arg(parseError.errorString());
return false;
}
config = document.object();
}
config.insert(key, value);
QDir dir(QFileInfo(configPath).path());
if (!dir.exists() && !dir.mkpath("."))
{
if (errorMessage)
*errorMessage = QString("Cannot create config directory: %1").arg(dir.path());
return false;
}
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
return writeBytesToFile(configPath, payload, errorMessage);
}
bool isRegistryManagedConfigKey(const QString& key)
{
// 服务端地址是编译期 qrc 配置,不进入注册表。
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
return key != kApiBaseUrlKey;
}
bool isPathInsideDirectory(const QString& path, const QString& directory)
{
if (path.isEmpty() || directory.isEmpty())
return false;
QString normalizedPath = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
QString normalizedDirectory = QDir::cleanPath(QFileInfo(directory).absoluteFilePath());
#ifdef Q_OS_WIN
normalizedPath = normalizedPath.toLower();
normalizedDirectory = normalizedDirectory.toLower();
#endif
return normalizedPath == normalizedDirectory
|| normalizedPath.startsWith(normalizedDirectory + QDir::separator());
}
bool isUserDataPath(const QString& path)
{
return isPathInsideDirectory(path, ConfigHelper::instance().dataRoot());
}
QJsonObject registryManagedConfigObject(const QJsonObject& source)
{
QJsonObject result;
for (auto it = source.constBegin(); it != source.constEnd(); ++it)
{
if (isRegistryManagedConfigKey(it.key()))
result.insert(it.key(), it.value());
}
return result;
}
#ifdef Q_OS_WIN
QString windowsErrorMessage(DWORD errorCode)
{
if (errorCode == ERROR_CANCELLED)
return QCoreApplication::translate("ConfigHelper", "The user canceled the administrator permission confirmation.");
return QCoreApplication::translate("ConfigHelper", "Windows error %1").arg(errorCode);
}
bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetPath,
const QString& originalError, QString* errorMessage)
{
// 安装到 C:\Program Files 等目录时,普通用户不能直接修改配置或运行态文件。
// 这里不让主进程一直以管理员运行,而是在确实需要写入时临时拉起自身完成单次写入。
const QMessageBox::StandardButton choice = QMessageBox::question(
nullptr,
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
QCoreApplication::translate("ConfigHelper", "The current 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,56 +329,450 @@ ConfigHelper& ConfigHelper::instance()
return obj; return obj;
} }
QString ConfigHelper::executableNameForCurrentPlatform(const QString& configuredValue,
const QString& fallbackBaseName)
{
QString name = configuredValue.trimmed();
if (name.isEmpty())
name = fallbackBaseName.trimmed();
#ifdef Q_OS_WIN
const QString fileName = QFileInfo(name).fileName();
if (!fileName.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive)
&& QFileInfo(fileName).suffix().isEmpty()) {
name += QStringLiteral(".exe");
}
#else
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
name.chop(4);
#endif
return name;
}
int ConfigHelper::runElevatedWriteCommandIfRequested()
{
const QStringList arguments = QCoreApplication::arguments();
if (arguments.contains(kElevatedConfigSetFlag))
{
const QString configPath = decodeArgument(findArgumentValue(arguments, kConfigPathPrefix));
const QString key = decodeArgument(findArgumentValue(arguments, kConfigKeyPrefix));
const QString value = decodeArgument(findArgumentValue(arguments, kConfigValuePrefix));
if (configPath.isEmpty() || key.isEmpty())
{
qWarning() << "Elevated config write arguments are incomplete.";
return 2;
}
QString errorMessage;
if (!writeConfigValueToFile(configPath, key, value, &errorMessage))
{
qWarning() << "Elevated config write failed:" << errorMessage;
return 3;
}
return 0;
}
if (arguments.contains(kElevatedFileWriteFlag))
{
const QString path = decodeArgument(findArgumentValue(arguments, kFilePathPrefix));
const QByteArray bytes = decodeBytes(findArgumentValue(arguments, kFileDataPrefix));
if (path.isEmpty())
{
qWarning() << "Elevated file write arguments are incomplete.";
return 2;
}
QString errorMessage;
if (!writeBytesToFile(path, bytes, &errorMessage))
{
qWarning() << "Elevated file write failed:" << errorMessage;
return 3;
}
return 0;
}
if (arguments.contains(kElevatedFileRemoveFlag))
{
const QString path = decodeArgument(findArgumentValue(arguments, kFilePathPrefix));
if (path.isEmpty())
{
qWarning() << "Elevated file remove arguments are incomplete.";
return 2;
}
QString errorMessage;
if (!removeFile(path, &errorMessage))
{
qWarning() << "Elevated file remove failed:" << errorMessage;
return 3;
}
return 0;
}
return -1;
}
bool ConfigHelper::writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
QString* errorMessage)
{
QString localError;
if (writeBytesToFile(path, data, &localError))
{
if (errorMessage)
errorMessage->clear();
return true;
}
#ifdef Q_OS_WIN
if (isUserDataPath(path))
{
if (errorMessage)
*errorMessage = localError;
return false;
}
if (data.size() > 24 * 1024)
{
if (errorMessage)
*errorMessage = QString("File is too large for elevated inline write: %1 bytes. Original error: %2")
.arg(data.size()).arg(localError);
return false;
}
return writeBytesWithElevation(path, data, localError, errorMessage);
#else
if (errorMessage)
*errorMessage = localError;
return false;
#endif
}
bool ConfigHelper::removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage)
{
QString localError;
if (removeFile(path, &localError))
{
if (errorMessage)
errorMessage->clear();
return true;
}
#ifdef Q_OS_WIN
if (isUserDataPath(path))
{
if (errorMessage)
*errorMessage = localError;
return false;
}
return removeFileWithElevation(path, localError, errorMessage);
#else
if (errorMessage)
*errorMessage = localError;
return false;
#endif
}
ConfigHelper::ConfigHelper() ConfigHelper::ConfigHelper()
{ {
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json"; m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
m_registryInstallId = QString::fromLatin1(QCryptographicHash::hash(
QDir::cleanPath(QApplication::applicationDirPath()).toUtf8(),
QCryptographicHash::Sha256).toHex());
migrateLegacyIniIfNeeded(); migrateLegacyIniIfNeeded();
syncRegistryFromConfigFileIfChanged();
removeRegistryValue(kApiBaseUrlKey);
qDebug() << "Loading app config path:" << m_configPath; qDebug() << "Loading app config path:" << m_configPath;
qDebug() << "File exists?" << QFile::exists(m_configPath); qDebug() << "File exists?" << QFile::exists(m_configPath);
qDebug() << "Registry installation id:" << m_registryInstallId;
} }
QString ConfigHelper::configPath() const QString ConfigHelper::configPath() const
{ {
return m_configPath; return m_configPath;
} }
QString ConfigHelper::installRoot() const QString ConfigHelper::installRoot() const
{ {
QString relativeRoot = getValue("Runtime", "install_root").trimmed(); QString relativeRoot = getValue("Runtime", "install_root").trimmed();
if (relativeRoot.isEmpty()) if (relativeRoot.isEmpty())
relativeRoot = "."; relativeRoot = ".";
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot)); return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
} }
QString ConfigHelper::runtimeRoot() const QString ConfigHelper::runtimeRoot() const
{ {
return QDir::cleanPath(QApplication::applicationDirPath()); return QDir::cleanPath(QApplication::applicationDirPath());
} }
QString ConfigHelper::dataRoot() const
{
QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
if (base.isEmpty())
base = QDir::homePath();
return QDir::cleanPath(QDir(base).filePath(
QStringLiteral("Marsco/UpdateClientSDK/installations/%1").arg(m_registryInstallId)));
}
QString ConfigHelper::dataConfigDir() const
{
return QDir(dataRoot()).filePath(QStringLiteral("config"));
}
QString ConfigHelper::clientIdentityPath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("client_identity.dat"));
}
QString ConfigHelper::policyPath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("version_policy.dat"));
}
QString ConfigHelper::localStatePath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("local_state.json"));
}
QString ConfigHelper::updateRoot() const QString ConfigHelper::updateRoot() const
{ {
return QDir(runtimeRoot()).filePath("update"); return QDir(dataRoot()).filePath("update");
} }
QString ConfigHelper::runtimeRelativePath() const QString ConfigHelper::runtimeRelativePath() const
{ {
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot())); QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
relative = QDir::cleanPath(relative); relative = QDir::cleanPath(relative);
if (relative == ".") if (relative == ".")
return QString(); return QString();
while (relative.startsWith("./")) while (relative.startsWith("./"))
relative = relative.mid(2); relative = relative.mid(2);
return relative.trimmed(); return relative.trimmed();
} }
QString ConfigHelper::lastError() const 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,105 +785,79 @@ QString ConfigHelper::getValue(const QString& section, const QString& key) const
return document.object().value(key).toVariant().toString(); return document.object().value(key).toVariant().toString();
} }
QString ConfigHelper::getValue(const QString& section, const QString& key) const
{
Q_UNUSED(section);
const QString embeddedValue = readEmbeddedValue(key);
if (!embeddedValue.isEmpty())
return embeddedValue;
if (!isRegistryManagedConfigKey(key))
return QString();
QString value;
if (readRegistryValue(key, &value))
return value;
return readFileValue(key);
}
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value) bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
{ {
Q_UNUSED(section); Q_UNUSED(section);
m_error.clear(); m_error.clear();
QFile input(m_configPath); return writeRegistryValue(key, value);
QJsonObject config;
if (input.exists())
{
if (!input.open(QIODevice::ReadOnly))
{
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
return false;
}
QJsonParseError parseError;
const QByteArray raw = input.readAll();
input.close();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
return false;
}
config = document.object();
}
config.insert(key, value);
QDir dir(QFileInfo(m_configPath).path());
if (!dir.exists() && !dir.mkpath("."))
{
m_error = QString("Cannot create config directory: %1").arg(dir.path());
return false;
}
QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly))
{
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
return false;
}
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
if (output.write(payload) != payload.size())
{
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
output.cancelWriting();
return false;
}
if (!output.commit())
{
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
return false;
}
return true;
} }
bool ConfigHelper::migrateLegacyIniIfNeeded() bool ConfigHelper::migrateLegacyIniIfNeeded()
{ {
if (QFile::exists(m_configPath)) if (QFile::exists(m_configPath))
return true; return true;
const QString legacyPath = QApplication::applicationDirPath() + "/client.ini"; const QString legacyPath = QApplication::applicationDirPath() + "/client.ini";
if (!QFile::exists(legacyPath)) if (!QFile::exists(legacyPath))
return false; return false;
QSettings ini(legacyPath, QSettings::IniFormat); QSettings ini(legacyPath, QSettings::IniFormat);
QJsonObject config; QJsonObject config;
const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) { const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) {
const QString value = ini.value(section + "/" + key, fallback).toString(); const QString value = ini.value(section + "/" + key, fallback).toString();
config.insert(key, value); config.insert(key, value);
}; };
copyText("App", "app_id"); copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App"); copyText("App", "app_name", "Marsco Demo App");
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("App", "launch_token");
copyText("License", "license_key"); copyText("License", "license_key");
copyText("Server", "api_base_url"); copyText("Server", "api_base_url");
copyText("Server", "client_token"); copyText("Server", "client_token");
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");
#ifdef Q_OS_WIN
config.insert("platform", "windows"); config.insert("platform", "windows");
#elif defined(Q_OS_LINUX)
config.insert("platform", "linux");
#else
config.insert("platform", "unknown");
#endif
config.insert("arch", "x64"); config.insert("arch", "x64");
QDir().mkpath(QFileInfo(m_configPath).path()); QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath); QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly)) if (!output.open(QIODevice::WriteOnly))
return false; return false;
output.write(QJsonDocument(config).toJson(QJsonDocument::Indented)); output.write(QJsonDocument(config).toJson(QJsonDocument::Indented));
const bool saved = output.commit(); const bool saved = output.commit();
if (saved) if (saved)
qDebug() << "Migrated legacy client.ini to" << m_configPath; qDebug() << "Migrated legacy client.ini to" << m_configPath;
return saved; return saved;
} }
+25 -2
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;
}; };
+299 -25
View File
@@ -1,5 +1,7 @@
#include "DeviceIdentityHelper.h" #include "DeviceIdentityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCoreApplication>
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
@@ -7,43 +9,315 @@
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo> #include <QSysInfo>
#include <QUuid>
#include <QTimer> #include <QTimer>
#include <QUuid>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;} namespace {
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){ QString manifestPublicKeyPath(const QString &installDir)
{
return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem"));
}
} // namespace
DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir)
: m_installDir(installDir)
{
}
QString DeviceIdentityHelper::deviceId() const
{
return m_deviceId;
}
QString DeviceIdentityHelper::errorString() const
{
return m_error;
}
bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64)
{
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false; Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"OpenSSL is unavailable, so device credential signature cannot be verified.");
return false;
#else #else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;} const QString keyPath = manifestPublicKeyPath(m_installDir);
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;} QFile keyFile(keyPath);
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok; if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) {
BIO_free(bio);
}
if (!publicKey) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath);
return false;
}
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx
&& EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(
ctx,
reinterpret_cast<const unsigned char *>(signature.constData()),
signature.size()) == 1;
if (ctx) {
EVP_MD_CTX_free(ctx);
}
EVP_PKEY_free(publicKey);
if (!ok) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential signature is invalid. The local identity file may not match this server.");
}
return ok;
#endif #endif
} }
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true; bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel)
{
// client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。
// 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。
QString credentialPath = ConfigHelper::instance().clientIdentityPath();
if (!QFile::exists(credentialPath)) {
credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat"));
}
QFile credentialFile(credentialPath);
if (!credentialFile.open(QIODevice::ReadOnly)) {
return false;
}
QJsonParseError parseError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1")
.arg(credentialPath);
return false;
}
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8();
const QString signature = wrapper.value(QStringLiteral("signature")).toString();
if (identityText.isEmpty() || !verifySignature(identityText, signature)) {
return false;
}
const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText);
const QJsonObject identity = identityDoc.object();
const QDateTime expiry = QDateTime::fromString(
identity.value(QStringLiteral("valid_until")).toString(),
Qt::ISODate);
const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId
&& identity.value(QStringLiteral("channel")).toString() == expectedChannel
&& !identity.value(QStringLiteral("license_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("installation_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("device_id")).toString().isEmpty();
if (!identityMatches) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential does not match this application, channel, license, installation or device.");
return false;
}
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License has expired. Please ask the administrator to issue a new License.");
return false;
}
m_deviceId = identity.value(QStringLiteral("device_id")).toString();
return true;
} }
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){ bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel)
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;} {
const QString trimmedBase=base.trimmed(); m_error.clear();
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;} return loadAndVerify(appId, channel);
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;} bool DeviceIdentityHelper::ensureIssued(
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;} const QString &apiBaseUrl,
ConfigHelper& config=ConfigHelper::instance(); const QString &clientToken,
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;}} const QString &appId,
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}}; const QString &channel,
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true; const QString &licenseKey)
{
// 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。
// 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。
m_error.clear();
if (loadAndVerify(appId, channel)) {
ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId);
return true;
}
const QString trimmedBaseUrl = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json.");
return false;
}
if (channel.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json.");
return false;
}
if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000");
return false;
}
if (clientToken.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"client_token is empty. Copy the client_token generated by the admin page into app_config.json.");
return false;
}
if (licenseKey.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License is empty. Create or select a License in the admin page, then copy the generated client configuration.");
return false;
}
ConfigHelper &config = ConfigHelper::instance();
QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
if (installationId.isEmpty()) {
installationId = QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
}
const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8();
const QString machineHash = QString::fromLatin1(
QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex());
const QJsonObject body{
{QStringLiteral("app_id"), appId},
{QStringLiteral("channel"), channel},
{QStringLiteral("license_key"), licenseKey},
{QStringLiteral("installation_id"), installationId},
{QStringLiteral("machine_hash"), machineHash},
};
QNetworkAccessManager manager;
QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
QEventLoop loop;
QTimer timer;
timer.setSingleShot(true);
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance()
.getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms"))
.toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) {
timeoutMs = 5000;
}
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString networkError = reply->errorString();
const QByteArray raw = reply->readAll();
reply->deleteLater();
if (status != 200) {
QJsonParseError responseError;
const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError);
QString serverMessage;
if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) {
const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail"));
serverMessage = detail.isObject()
? detail.toObject().value(QStringLiteral("msg")).toString()
: detail.toString();
}
if (serverMessage.isEmpty())
serverMessage = QString::fromUtf8(raw).trimmed();
if (status == 0) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms")
.arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs));
} else {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5")
.arg(trimmedBaseUrl, QString::number(status), appId, channel,
serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "<empty response>") : serverMessage);
}
return false;
}
const QJsonDocument responseDoc = QJsonDocument::fromJson(raw);
const QJsonObject response = responseDoc.object();
if (response.value(QStringLiteral("identity_text")).toString().isEmpty()
|| response.value(QStringLiteral("signature")).toString().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server returned an invalid device identity response.");
return false;
}
const QJsonObject wrapper{
{QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))},
{QStringLiteral("signature"), response.value(QStringLiteral("signature"))},
};
const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
const QString credentialPath = config.clientIdentityPath();
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2")
.arg(credentialPath, writeError);
return false;
}
if (!loadAndVerify(appId, channel)) {
return false;
}
if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
return true;
} }
+22 -9
View File
@@ -1,15 +1,28 @@
#pragma once #pragma once
#include <QByteArray>
#include <QString> #include <QString>
class DeviceIdentityHelper { class DeviceIdentityHelper {
public: public:
explicit DeviceIdentityHelper(const QString& installDir); explicit DeviceIdentityHelper(const QString &installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey); bool ensureIssued(
bool verifyLocal(const QString& appId, const QString& channel); const QString &apiBaseUrl,
QString deviceId() const; const QString &clientToken,
QString errorString() const; const QString &appId,
const QString &channel,
const QString &licenseKey);
bool verifyLocal(const QString &appId, const QString &channel);
QString deviceId() const;
QString errorString() const;
private: private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel); bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64); bool verifySignature(const QByteArray &payload, const QString &signatureBase64);
QString m_installDir, m_deviceId, m_error;
QString m_installDir;
QString m_deviceId;
QString m_error;
}; };
+80 -25
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)
{ {
@@ -13,30 +28,70 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
{ {
if (QFile::exists(dst)) if (QFile::exists(dst))
{ {
if (!QFile::remove(dst)) if (!QFile::remove(dst))
{ {
qDebug() << "无法删除旧文件:" << dst; qDebug() << "Cannot remove old file:" << dst;
return false; return false;
} }
} }
return QFile::copy(src, dst); return QFile::copy(src, dst);
} }
bool FileHelper::isProcessRunning(const QString &exeName) bool FileHelper::isProcessRunning(const QString &exeName)
{ {
QProcess process; const QString imageName = processImageName(exeName);
process.start("tasklist"); if (imageName.isEmpty())
process.waitForFinished(); return false;
QString output = process.readAllStandardOutput();
return output.contains(exeName, Qt::CaseInsensitive); QProcess process;
} #ifdef Q_OS_WIN
process.start(QStringLiteral("tasklist"), QStringList{
bool FileHelper::killProcess(const QString &exeName) QStringLiteral("/FI"),
{ QStringLiteral("IMAGENAME eq %1").arg(imageName)
if (!isProcessRunning(exeName)) });
return true; process.waitForFinished();
QProcess process; const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
process.start("taskkill /f /im " + exeName); return output.contains(imageName, Qt::CaseInsensitive);
process.waitForFinished(1000); #elif defined(Q_OS_UNIX)
return !isProcessRunning(exeName); process.start(QStringLiteral("pgrep"), QStringList{
} QStringLiteral("-x"),
imageName
});
process.waitForFinished(1000);
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
#else
return false;
#endif
}
bool FileHelper::killProcess(const QString &exeName)
{
if (!isProcessRunning(exeName))
return true;
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return true;
QProcess process;
#ifdef Q_OS_WIN
process.start(QStringLiteral("taskkill"), QStringList{
QStringLiteral("/f"),
QStringLiteral("/im"),
imageName
});
#elif defined(Q_OS_UNIX)
process.start(QStringLiteral("pkill"), QStringList{
QStringLiteral("-x"),
imageName
});
#else
return false;
#endif
process.waitForFinished(3000);
for (int i = 0; i < 20; ++i) {
if (!isProcessRunning(imageName))
return true;
QThread::msleep(100);
}
return false;
}
+70 -67
View File
@@ -1,67 +1,70 @@
#include "HttpHelper.h" #include "HttpHelper.h"
#include <QNetworkProxy> #include <QNetworkProxy>
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QFile> #include <QFile>
#include <QDir> #include <QDir>
#include <QApplication> #include <QApplication>
#include <QTimer> #include <QTimer>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, void HttpHelper::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)
{ {
QNetworkAccessManager* manager = new QNetworkAccessManager(); QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy); manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url); QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header // Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token"); QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8()); req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat")); QString identityPath = ConfigHelper::instance().clientIdentityPath();
if (identity.open(QIODevice::ReadOnly)) if (!QFile::exists(identityPath))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); identityPath = QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat");
QFile identity(identityPath);
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); if (identity.open(QIODevice::ReadOnly))
qDebug() << "=== POST Request ==="; req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
qDebug() << "Url:" << url;
qDebug() << "Body:" << data; QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
QNetworkReply* reply = manager->post(req, data); qDebug() << "Url:" << url;
QEventLoop loop; qDebug() << "Body:" << data;
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); QNetworkReply* reply = manager->post(req, data);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000; QEventLoop loop;
QTimer timer; bool timeoutOk = false;
timer.setSingleShot(true); int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
QObject::connect(&timer, &QTimer::timeout, [&]() { if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
if (reply && reply->isRunning()) { QTimer timer;
qDebug() << "Request timeout, abort:" << url; timer.setSingleShot(true);
reply->abort(); QObject::connect(&timer, &QTimer::timeout, [&]() {
} if (reply && reply->isRunning()) {
}); qDebug() << "Request timeout, abort:" << url;
reply->abort();
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); }
timer.start(timeoutMs); });
loop.exec();
timer.stop(); QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
int retCode = 0; loop.exec();
QJsonObject retObj; timer.stop();
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); int retCode = 0;
const QByteArray respData = reply->readAll(); QJsonObject retObj;
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData; retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
retObj = QJsonDocument::fromJson(respData).object(); const QByteArray respData = reply->readAll();
} if (!respData.isEmpty()) {
if (reply->error() != QNetworkReply::NoError) qDebug() << "Server raw response:" << respData;
{ retObj = QJsonDocument::fromJson(respData).object();
qDebug() << "Network error code:" << reply->error(); }
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); if (reply->error() != QNetworkReply::NoError)
} {
qDebug() << "Network error code:" << reply->error();
callback(retCode, retObj); qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
}
reply->deleteLater();
manager->deleteLater(); callback(retCode, retObj);
}
reply->deleteLater();
manager->deleteLater();
}
+2 -2
View File
@@ -11,7 +11,7 @@
class HttpHelper class HttpHelper
{ {
public: public:
// 每次调用独立创建manager,不做成成员变量 // Create an independent manager for each call instead of keeping it as a member.
static void postRequest(const QString& url, const QJsonObject& jsonBody, static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback); std::function<void(int code, const QJsonObject& resp)> callback);
}; };
+167 -104
View File
@@ -1,153 +1,216 @@
#include "IntegrityHelper.h" #include "IntegrityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.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>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
IntegrityHelper::IntegrityHelper(const QString& installDir) IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {} : m_installDir(QDir::cleanPath(installDir)) {}
QString IntegrityHelper::errorString() const { return m_error; } 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)); const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":"); && !clean.startsWith("../") && !clean.contains(":");
} }
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{ {
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{ QSet<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat" "config/client_identity.dat", "config/version_policy.dat"
}; };
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) { if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{ const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat" "config/client_identity.dat", "config/version_policy.dat"
}; };
for (const QString& protectedPath : runtimeProtected) for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath); protectedPaths.insert(runtimePrefix + "/" + protectedPath);
} }
return protectedPaths.contains(p); return protectedPaths.contains(p);
} }
QString IntegrityHelper::sha256(const QString& filePath) const QString IntegrityHelper::sha256(const QString& filePath) const
{ {
QFile file(filePath); QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return {}; if (!file.open(QIODevice::ReadOnly)) return {};
QCryptographicHash hash(QCryptographicHash::Sha256); QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd()) hash.addData(file.read(1024 * 1024)); while (!file.atEnd()) hash.addData(file.read(1024 * 1024));
return QString::fromLatin1(hash.result().toHex()); return QString::fromLatin1(hash.result().toHex());
} }
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)) {
const QByteArray keyData = keyFile.readAll(); m_error = QCoreApplication::translate("IntegrityHelper",
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); "Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; .arg(keyPath);
if (bio) BIO_free(bio); return false;
if (!key) { m_error = "manifest public key invalid"; return false; } }
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); const QByteArray keyData = keyFile.readAll();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 if (bio) BIO_free(bio);
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; if (!key) {
if (ctx) EVP_MD_CTX_free(ctx); m_error = QCoreApplication::translate("IntegrityHelper",
EVP_PKEY_free(key); "Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
if (!ok) m_error = "manifest RSA signature invalid"; .arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
}
return ok; return ok;
#endif #endif
} }
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
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(
"update/manifest_cache/manifest_" + version + ".json"); "update/manifest_cache/manifest_" + version + ".json");
if (!QFile::exists(cachePath)) if (!QFile::exists(cachePath))
cachePath = legacyCachePath; cachePath = legacyCachePath;
QFile cache(cachePath); QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; } if (!cache.open(QIODevice::ReadOnly)) {
QJsonParseError wrapperError; 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;
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(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false; if (manifestText.isEmpty() || signature.isEmpty()) {
m_error = QCoreApplication::translate("IntegrityHelper",
QJsonParseError manifestError; "Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath);
return false;
}
if (!verifySignature(manifestText, signature)) return false;
QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); 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 if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel || manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) { || manifest.value("version").toString() != version) {
m_error = "manifest identity does not match local application"; return false; m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
} }
QSet<QString> declaredExecutables; QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) { for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject(); const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString()); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; } if (!safeRelativePath(path)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
.arg(version, path);
return false;
}
if (runtimeProtectedPath(path)) continue; 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 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(); const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
} }
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));
const QString folded = relative.toCaseFolded(); const QString folded = relative.toCaseFolded();
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
const bool runtimeWorkDir = !runtimePrefix.isEmpty() const bool runtimeWorkDir = !runtimePrefix.isEmpty()
&& (folded.startsWith(runtimePrefix + "/update/") && (folded.startsWith(runtimePrefix + "/update/")
|| 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(); const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = "undeclared executable or plugin: " + relative; return false; m_error = QCoreApplication::translate("IntegrityHelper",
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
return false;
} }
} }
return true; return true;
} }
+20 -20
View File
@@ -1,20 +1,20 @@
#pragma once #pragma once
#include <QString> #include <QString>
class IntegrityHelper class IntegrityHelper
{ {
public: public:
explicit IntegrityHelper(const QString& installDir); explicit IntegrityHelper(const QString& installDir);
bool verifyInstalledVersion(const QString& appId, const QString& channel, bool verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version); const QString& version);
QString errorString() const; QString errorString() const;
private: private:
bool verifySignature(const QByteArray& payload, const QString& signatureBase64); bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
bool safeRelativePath(const QString& path) const; bool safeRelativePath(const QString& path) const;
bool runtimeProtectedPath(const QString& path) const; bool runtimeProtectedPath(const QString& path) const;
QString sha256(const QString& filePath) const; QString sha256(const QString& filePath) const;
QString m_installDir; QString m_installDir;
QString m_error; QString m_error;
}; };
+125 -109
View File
@@ -1,147 +1,163 @@
#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)
{ {
m_filePath = m_baseDir + "/" + relativePath; const QString defaultState = QStringLiteral("config/local_state.json");
if (relativePath == defaultState)
m_filePath = ConfigHelper::instance().localStatePath();
else if (QDir::isAbsolutePath(relativePath))
m_filePath = relativePath;
else
m_filePath = m_baseDir + "/" + relativePath;
QFile file(m_filePath); 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()},
{"last_online_verified_at", QString()}, {"last_online_verified_at", QString()},
{"last_success_version", QString()} {"last_success_version", QString()}
}; };
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;
} }
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;
} }
QByteArray raw = file.readAll(); QByteArray raw = file.readAll();
file.close(); file.close();
QJsonParseError parseError; QJsonParseError parseError;
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;
} }
m_state = doc.object(); m_state = doc.object();
m_loaded = true; m_loaded = true;
return true; return true;
} }
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;
} }
bool LocalStateHelper::isLoaded() const bool LocalStateHelper::isLoaded() const
{ {
return m_loaded; return m_loaded;
} }
QString LocalStateHelper::errorString() const QString LocalStateHelper::errorString() const
{ {
return m_error; return m_error;
} }
qint64 LocalStateHelper::maxPolicySeq() const qint64 LocalStateHelper::maxPolicySeq() const
{ {
return m_state.value("max_policy_seq").toVariant().toLongLong(); return m_state.value("max_policy_seq").toVariant().toLongLong();
} }
QDateTime LocalStateHelper::lastSuccessRunAt() const QDateTime LocalStateHelper::lastSuccessRunAt() const
{ {
return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate); return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate);
} }
QDateTime LocalStateHelper::lastOnlineVerifiedAt() const QDateTime LocalStateHelper::lastOnlineVerifiedAt() const
{ {
return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate); return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate);
} }
QString LocalStateHelper::lastSuccessVersion() const QString LocalStateHelper::lastSuccessVersion() const
{ {
return m_state.value("last_success_version").toString(); return m_state.value("last_success_version").toString();
} }
bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const
{ {
if (!m_loaded) if (!m_loaded)
return false; return false;
return policySeq < maxPolicySeq(); return policySeq < maxPolicySeq();
} }
bool LocalStateHelper::isSystemTimeRewound() const bool LocalStateHelper::isSystemTimeRewound() const
{ {
if (!m_loaded) if (!m_loaded)
return false; return false;
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
const QDateTime lastRun = lastSuccessRunAt(); const QDateTime lastRun = lastSuccessRunAt();
const QDateTime lastOnline = lastOnlineVerifiedAt(); const QDateTime lastOnline = lastOnlineVerifiedAt();
return (lastRun.isValid() && now < lastRun) return (lastRun.isValid() && now < lastRun)
|| (lastOnline.isValid() && now < lastOnline); || (lastOnline.isValid() && now < lastOnline);
} }
void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq) void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq)
{ {
if (!m_loaded) if (!m_loaded)
m_loaded = true; m_loaded = true;
m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["last_success_version"] = currentVersion; m_state["last_success_version"] = currentVersion;
if (policySeq > maxPolicySeq()) if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq; m_state["max_policy_seq"] = policySeq;
} }
void LocalStateHelper::updateOnlineVerified(qint64 policySeq) void LocalStateHelper::updateOnlineVerified(qint64 policySeq)
{ {
if (!m_loaded) if (!m_loaded)
m_loaded = true; m_loaded = true;
m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
if (policySeq > maxPolicySeq()) if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq; m_state["max_policy_seq"] = policySeq;
} }
+33 -33
View File
@@ -1,33 +1,33 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QJsonObject> #include <QJsonObject>
#include <QDateTime> #include <QDateTime>
class LocalStateHelper class LocalStateHelper
{ {
public: public:
explicit LocalStateHelper(const QString& baseDir); explicit LocalStateHelper(const QString& baseDir);
bool loadState(const QString& relativePath = "config/local_state.json"); bool loadState(const QString& relativePath = "config/local_state.json");
bool saveState() const; bool saveState() const;
bool isLoaded() const; bool isLoaded() const;
QString errorString() const; QString errorString() const;
qint64 maxPolicySeq() const; qint64 maxPolicySeq() const;
QDateTime lastSuccessRunAt() const; QDateTime lastSuccessRunAt() const;
QDateTime lastOnlineVerifiedAt() const; QDateTime lastOnlineVerifiedAt() const;
QString lastSuccessVersion() const; QString lastSuccessVersion() const;
bool isPolicySeqRolledBack(qint64 policySeq) const; bool isPolicySeqRolledBack(qint64 policySeq) const;
bool isSystemTimeRewound() const; bool isSystemTimeRewound() const;
void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq); void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq);
void updateOnlineVerified(qint64 policySeq); void updateOnlineVerified(qint64 policySeq);
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;
}; };
+155 -82
View File
@@ -1,116 +1,189 @@
#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>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
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());
} }
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText) bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
{ {
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear(); m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
return isValid(); return isValid();
} }
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
{ {
QJsonObject copy = policy; copy.remove("signature"); QJsonObject copy = policy; copy.remove("signature");
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject { auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end()); QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end());
QJsonObject sorted; QJsonObject sorted;
for (const QString& key : keys) { for (const QString& key : keys) {
QJsonValue value = obj.value(key); QJsonValue value = obj.value(key);
if (value.isObject()) value = self(self, value.toObject()); if (value.isObject()) value = self(self, value.toObject());
else if (value.isArray()) { else if (value.isArray()) {
QJsonArray array; QJsonArray array;
for (QJsonValue item : value.toArray()) { for (QJsonValue item : value.toArray()) {
if (item.isObject()) item = self(self, item.toObject()); if (item.isObject()) item = self(self, item.toObject());
array.append(item); array.append(item);
} }
value = array; value = array;
} }
sorted.insert(key, value); sorted.insert(key, value);
} }
return sorted; return sorted;
}; };
return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact); return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact);
} }
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)) {
const QByteArray keyData = keyFile.readAll(); m_error = QCoreApplication::translate(
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); "PolicyHelper",
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; "Cannot open version policy public key: %1. Error: %2.")
if (bio) BIO_free(bio); .arg(keyPath, keyFile.errorString());
if (!key) { m_error = "Invalid policy public key"; return false; } return false;
EVP_MD_CTX* ctx = EVP_MD_CTX_new(); }
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); const QByteArray keyData = keyFile.readAll();
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1; if (bio) BIO_free(bio);
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); if (!key) {
if (!ok) m_error = "Invalid RSA policy signature"; m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy public key is invalid: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
}
return ok; 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)) {
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); m_error = QCoreApplication::translate(
return verifySignature(payload, m_policy.value("signature").toString()); "PolicyHelper",
} "Version policy is missing required field: %1.")
QString PolicyHelper::errorString() const { return m_error; } .arg(key);
bool PolicyHelper::isVersionAllowed(const QString& version) const { return false;
if (!isValid()) return false; }
for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false; }
return true; if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
} m_error = QCoreApplication::translate(
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); } "PolicyHelper",
"Version policy signature algorithm is unsupported: %1.")
.arg(m_policy.value("signature_alg").toString());
return false;
}
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString());
}
QString PolicyHelper::errorString() const { return m_error; }
bool PolicyHelper::isVersionAllowed(const QString& version) const {
if (!isValid()) return false;
for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false;
return true;
}
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); }
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); } bool PolicyHelper::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);
return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry; return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry;
} }
qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; } qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; }
QString PolicyHelper::message() const { return m_policy.value("message").toString(); } QString PolicyHelper::message() const { return m_policy.value("message").toString(); }
+31 -30
View File
@@ -1,33 +1,34 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QJsonObject> #include <QJsonObject>
class PolicyHelper class PolicyHelper
{ {
public: public:
explicit PolicyHelper(const QString& baseDir); explicit PolicyHelper(const QString& baseDir);
bool loadPolicy(const QString& relativePath = "config/version_policy.dat"); bool loadPolicy(const QString& relativePath = "config/version_policy.dat");
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString()); bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const; bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
bool isValid() const; bool isValid() const;
QString errorString() const; QString errorString() const;
bool isVersionAllowed(const QString& currentVersion) const; bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const; bool allowRun() const;
bool forceUpdate() const; bool forceUpdate() const;
bool allowRollback() const; bool allowRollback() const;
bool isOfflineAllowed() const; bool isOfflineAllowed() const;
bool gitTagsEnabled() const;
bool isExpired() const; bool isExpired() const;
qint64 policySeq() const; qint64 policySeq() const;
QString message() const; QString message() const;
private: private:
QByteArray canonicalPolicyBytes(const QJsonObject& obj) const; QByteArray canonicalPolicyBytes(const QJsonObject& obj) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const; bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const;
QString m_baseDir; QString m_baseDir;
QJsonObject m_policy; QJsonObject m_policy;
QString m_signedText; QString m_signedText;
mutable QString m_error; mutable QString m_error;
bool m_loaded = false; bool m_loaded = false;
}; };
+141 -51
View File
@@ -1,90 +1,180 @@
#include "TicketHelper.h" #include "TicketHelper.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QMessageAuthenticationCode> #include <QMessageAuthenticationCode>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
#include <QStringList>
#include <QUuid> #include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
{ {
return QMessageAuthenticationCode::hash( return QMessageAuthenticationCode::hash(
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex(); QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
} }
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId, bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret, const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage) QString* ticketPath, QString* errorMessage)
{ {
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();
QJsonObject payload{ QJsonObject payload{
{"app_id", appId}, {"device_id", deviceId}, {"version", version}, {"app_id", appId}, {"device_id", deviceId}, {"version", version},
{"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)}, {"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)},
{"issued_at", now.toString(Qt::ISODate)}, {"issued_at", now.toString(Qt::ISODate)},
{"expires_at", now.addSecs(60).toString(Qt::ISODate)}, {"expires_at", now.addSecs(60).toString(Qt::ISODate)},
{"signature_alg", "HMAC-SHA256"} {"signature_alg", "HMAC-SHA256"}
}; };
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)) {
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket directory: %1.")
.arg(dirPath);
}
return false;
}
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
QSaveFile file(path); 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);
if (ticketPath) *ticketPath = path; if (ticketPath) *ticketPath = path;
return true; return true;
} }
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
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();
const QByteArray actual = wrapper.value("signature").toString().toLatin1(); const QByteArray actual = wrapper.value("signature").toString().toLatin1();
const QByteArray expected = ticketMac(payload, secret); const QByteArray expected = ticketMac(payload, secret);
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;
+13 -13
View File
@@ -1,13 +1,13 @@
#pragma once #pragma once
#include <QString> #include <QString>
class TicketHelper class TicketHelper
{ {
public: public:
static bool createTicket(const QString& appId, const QString& deviceId, static bool createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret, const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage = nullptr); QString* ticketPath, QString* errorMessage = nullptr);
static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion, const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage = nullptr); const QString& secret, QString* errorMessage = nullptr);
}; };
@@ -0,0 +1,81 @@
客户端文档入口
==============
你第一次打开 update-client/Docs 时,先看这一份。这里告诉你每份文档是干什么的,以及不同角色应该从哪里开始。
文档阅读顺序
============
1. 01-客户端接入打包部署指南.md
适合 SDK 接入方、测试人员和交付人员。按“生成 SDK -> 放进业务软件 -> 生成配置 -> 联调 -> 打最终包”的顺序写。
2. 02-编译环境和第三方依赖说明.md
适合需要编译 Launcher、Updater、Bootstrap 的人。说明 Windows/Linux 下 Qt、OpenSSL、thirdparty/ 和 CMake 怎么准备。
3. ../i18n/ReadMe.txt
适合维护界面文案的人。说明新增 tr() 后怎么更新 .ts、生成 .qm,并把翻译文件打进 qrc。
常用任务入口
============
如果你只是拿到 SDK 接入业务软件:
```text
读 01-客户端接入打包部署指南.md 的“三、你:把 SDK 放进业务软件目录”和“四、你:生成并填写 app_config.json”。
```
如果你要重新打 Windows SDK 包:
```powershell
cd update-client
.\scripts\package-sdk.ps1 -SourceDir .\out\bin\Release -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
```
如果你要重新打 Linux SDK 包:
```bash
cd update-client
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
bash ./scripts/package-sdk.sh --source-dir ./out/linux/bin --output-dir ./dist/UpdateClientSDK-linux --archive ./dist/UpdateClientSDK-linux.tar.gz --sdk-version 0.1.0
```
客户端配置速记
==============
config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。后续运行配置优先从 QSettings 读取。
config/server_config.json 是编译期服务端地址配置源文件。它通过 config/server_config.qrc 编进 Launcher / Updater / MainApp,不写入 app_config.json,也不写入注册表。修改 api_base_url 后必须重新编译客户端程序才会生效。
如果 config/app_config.json 内容被修改,下一次启动时会按解析后的 JSON 内容 SHA256 判断变化并重新导入注册表。
非空 app_config.json 成功导入注册表后会自动清空为 {},文件保留不删除,方便下次直接粘贴管理后台生成的新配置。
Windows 注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。
Linux 配置位置由 Qt QSettings 决定,通常在当前用户 home 目录的 .config/Marsco/UpdateClientSDK.conf 一类路径下。
Windows 运行态数据目录类似:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\。
如果检测到 app_config.json 发生变化,SDK 会删除当前用户数据目录里的 client_identity.dat、version_policy.dat 和 local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
正常启动成功后不要删除这些状态文件,它们用于本地身份、离线策略和安全状态。
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
接入新软件时通常需要修改:
1. app_id、app_name、channel、current_version。
2. client_token、license_key、launch_token。
3. config/server_config.json 里的 api_base_url。
4. main_executable:团队业务主程序文件名。
5. launcher_executable、updater_executable、bootstrap_executable。
6. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
Windows 完整格式参考 update-client/config/app_config.example.jsonLinux 完整格式参考 update-client/config/app_config.linux.example.json。
运行时生成的 client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。app_config.json 可以作为部署模板,但不要把某台机器运行后产生的临时状态混进去。
Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验。签名 Manifest 缓存现在默认保存在当前用户数据目录:
Windows%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache
Linux$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache,未设置 XDG_DATA_HOME 时通常是 ~/.local/share。
打包脚本会优先从用户数据目录读取,也兼容旧版 Release 目录里的 update/manifest_cache。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名;确认客户端程序已用正确的 config/server_config.json 编译。
4. 在 PowerShell 执行:
powershell -ExecutionPolicy Bypass -File .\scripts\package-client.ps1 -ConfigFile .\config\app_config.json
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
@@ -0,0 +1,432 @@
# UpdateClientSDK 客户端接入、打包和部署指南
本文按“维护者打包 SDK -> 你接入业务软件 -> 联调测试 -> 生成最终客户端包”的顺序说明。你拿到这份文档后,按章节一步一步做即可。
## 先看这里:你要做哪件事
| 你的目标 | 直接看哪一节 |
| --- | --- |
| 重新生成给别人用的 SDK 包 | 二、维护者:生成 SDK 包 |
| 把 SDK 放到 SimCAE 或其他业务软件目录 | 三、你:把 SDK 放进业务软件目录 |
| 从后台生成 `app_config.json` 和 qrc 服务端配置 | 四、你:生成并填写客户端配置 |
| 给业务主程序接入启动保护代码 | 五、你:业务主程序接入要求 |
| 验证升级、回滚、健康检查 | 六、你:联调测试 |
| 生成最终交付给用户的客户端包 | 七、维护者:生成最终客户端包 |
## 一、这个 SDK 是什么
UpdateClientSDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力做成一组独立程序,让业务软件通过这些程序完成检查更新、下载、安装、回滚和启动保护。
SDK 核心程序:
- `Launcher.exe` / `Launcher`:用户入口。检查版本、验证授权和策略,决定直接启动业务主程序或进入升级流程。
- `Updater.exe` / `Updater`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe` / `Bootstrap`:处理运行中可能被占用的 EXE/DLL 或 Linux 可执行文件替换。
- `config/app_config.json`:部署配置源文件。启动时会同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。
- `config/server_config.json`:编译期服务端地址配置源文件,通过 `config/server_config.qrc` 编进 Launcher / Updater / MainApp,不写入 `app_config.json` 或注册表。
- `config/manifest_public_key.pem`:Manifest 签名公钥,用来验证服务端发布包没有被篡改。
## 二、维护者:生成 SDK 包
这一节是 SDK 维护者操作。接入方通常只需要拿到 `UpdateClientSDK.zip`
打包前确认:
1. 已在 Windows 上用 Release 配置编译完成,输出目录里有 `Launcher.exe``Updater.exe``Bootstrap.exe`
2. `config/manifest_public_key.pem` 和服务端使用的私钥是一对。
3. `update-client/Docs` 里的 Markdown 文档会随 SDK 一起打包,是默认接入说明来源。
4. Word 接入说明是可选增强。如果本地有文件名包含 `SDK``.docx`,打包脚本会额外复制到 SDK 根目录;如果没有,也不会影响 SDK 打包。
5. 如果业务软件本身已经带 Qt DLL,通常不要把 SDK 的 Qt 运行库打进去,避免 Qt 版本混用。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
生成结果:
```text
dist/
UpdateClientSDK/
sdk_manifest.json
Docs/
00-先读我-客户端文档入口.txt
01-客户端接入打包部署指南.md
02-编译环境和第三方依赖说明.md
bin/
Launcher.exe
Updater.exe
Bootstrap.exe
...
config/
app_config.json
manifest_public_key.pem
scripts/
install-sdk.ps1
package-client.ps1
package-sdk.ps1
SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现
UpdateClientSDK.zip
```
`dist/UpdateClientSDK.zip` 发给接入方即可。
可选参数:
- `-IncludeDemoMainApp`:把仓库里的 Demo 主程序 `MainApp.exe` 也打进 SDK,方便演示。
- `-IncludeQtRuntime`:把 Qt 运行库也打进 SDK。只有业务软件本身不带 Qt 时才建议使用。
Linux SDK 打包方式:
```bash
cd /home/laluo/project/update-client
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
bash ./scripts/package-sdk.sh \
--source-dir ./out/linux/bin \
--output-dir ./dist/UpdateClientSDK-linux \
--archive ./dist/UpdateClientSDK-linux.tar.gz \
--sdk-version 0.1.0
```
Linux SDK 包里核心程序名不带 `.exe`
```text
dist/
UpdateClientSDK-linux/
sdk_manifest.json
Docs/
00-先读我-客户端文档入口.txt
01-客户端接入打包部署指南.md
02-编译环境和第三方依赖说明.md
bin/
Launcher
Updater
Bootstrap
Common/
ConfigHelper.h
ConfigHelper.cpp
TicketHelper.h
TicketHelper.cpp
config/
app_config.json
manifest_public_key.pem
scripts/
package-sdk.sh
package-client.sh
SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现
UpdateClientSDK-linux.tar.gz
```
## 三、你:把 SDK 放进业务软件目录
假设业务软件目录是:
```text
D:\SimCAE\
bin\
SimCAE.exe
Qt5Core.dll
...
Licenses\
installerResources\
```
推荐把 SDK 放到 `bin` 目录,和 `SimCAE.exe` 同级;后台发布新版本时仍选择整个 `D:\SimCAE\` 作为发布根目录。
先解压 SDK
```powershell
Expand-Archive D:\交付\UpdateClientSDK.zip -DestinationPath D:\SimCAE_SDK -Force
```
再安装到业务软件的 `bin` 目录:
```powershell
cd D:\SimCAE\bin
D:\SimCAE_SDK\scripts\install-sdk.ps1 `
-SdkRoot D:\SimCAE_SDK `
-ReleaseDir .
```
安装后目录应类似:
```text
D:\SimCAE\
bin\
Launcher.exe
Updater.exe
Bootstrap.exe
SimCAE.exe
config\
app_config.json
manifest_public_key.pem
Qt5Core.dll
...
Licenses\
installerResources\
```
如果你需要重新覆盖 `config/app_config.json`,执行安装脚本时加 `-OverwriteConfig`
```powershell
D:\SimCAE_SDK\scripts\install-sdk.ps1 `
-SdkRoot D:\SimCAE_SDK `
-ReleaseDir D:\SimCAE\bin `
-OverwriteConfig
```
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应复用 SimCAE 的 `Qt5*.dll``platforms/``imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则可能出现“无法定位程序输入点”一类错误。
## 四、你:生成并填写客户端配置
最推荐的方式是在服务端管理后台生成客户端配置:
1. 浏览器打开服务端管理后台,例如 `http://服务器IP:8000/`
2. 登录后台。
3. 创建或选择应用,例如 `app_id=simcae`
4. 创建 License。
5. 在“客户端配置生成”区域选择应用、渠道、License 和主程序名。
6. 点击生成配置。
7. 复制“客户端 app_config.json”,覆盖 `D:\SimCAE\bin\config\app_config.json`
8. 复制“qrc 服务端配置 server_config.json”,覆盖 `update-client\config\server_config.json`,然后重新编译 Launcher / Updater / Bootstrap。这个文件会被 `config/server_config.qrc` 编进程序,不会放进用户机器的 `app_config.json` 或注册表。
配置同步规则:
- `app_config.json` 是部署配置源文件,适合交付、复制、人工修改。
- `api_base_url` 不再属于 `app_config.json` 字段。它只存在于 `config/server_config.json`,并通过 qrc 编进程序。
- 交付给你的 SDK 运行包不会包含 `server_config.json``server_config.qrc`。它们是编译材料,不是运行配置;修改 SDK 包里的文件不会改变已经编译好的 `Launcher.exe`
- Launcher / Updater / MainApp 启动时会计算 `app_config.json` 解析后的 JSON 内容 SHA256;如果 JSON 内容和上次导入时不同,就把文件里的配置重新写入当前 Windows 用户的注册表。
- 后续运行时优先从注册表读取配置,不再每次直接读 JSON。
- 运行过程中产生的动态值,例如首次输入的 `license_key`、服务端返回的 `device_id`、升级后的 `current_version`,会写入注册表。
- 为减少明文暴露,SDK 成功把非空 `app_config.json` 导入注册表后,会把 `app_config.json` 内容自动清空为 `{}`,但不会删除这个文件。以后你从管理后台复制新的客户端配置时,直接覆盖这个文件即可。
- SDK 会同步更新注册表里的 `source_sha256`,所以 `{}` 不会在下一次启动时反向覆盖注册表配置。
- 如果你手动修改了 `app_config.json`,下一次启动会重新导入并覆盖注册表里的同名字段。
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除当前用户数据目录里的 `client_identity.dat``version_policy.dat``local_state.json`,避免继续使用旧 License、旧设备身份、旧版本策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
- 正常启动成功后,不要删除 `client_identity.dat``version_policy.dat``local_state.json`。它们分别用于本地设备身份、离线策略和防回滚/防时间倒退,删除后会影响离线启动或导致重新授权。
- 注册表按安装目录隔离;同一台电脑上多个安装目录不会互相覆盖配置。
- 注册表位置为 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`
关键字段说明:
- `app_id`:服务端应用 ID,要和管理后台里的应用一致。
- `app_name`:应用显示名称。
- `channel`:发布渠道,例如 `stable``beta``dev`
- `current_version`:客户端当前初始版本,必须和后台已发布的版本一致。
- `client_protocol`:客户端协议号,当前建议为 `3`
- `launch_token`:本机启动票据 HMAC 密钥。服务端生成配置时会填默认值;正式部署建议按项目统一修改。
- `license_key`:管理后台创建 License 后生成的授权码。为空时首次启动 `Launcher.exe` 会弹窗让用户输入并保存到注册表;如果授权错误或过期,也会提示重新输入。
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,必须和服务端一致。
- `device_id`:设备 ID。一般可以留空,首次启动时 SDK 会向服务端登记并写入注册表。
- `install_root`:被更新的安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径。SDK 放在 `bin` 且主程序也在 `bin` 时填 `SimCAE.exe`
- `launcher_executable``updater_executable``bootstrap_executable`:通常不用改。
- `platform``arch`:当前为 `windows``x64`
典型配置:
```json
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "MARSCO-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
```
对应的 `config/server_config.json` 示例:
```json
{
"api_base_url": "http://192.168.229.128:8000"
}
```
## 五、你:业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
接入位置:
```text
main / WinMain 开头,创建主窗口之前
```
当前仓库里的 `update-client/MainApp/main.cpp` 是接入示例,已经实现:
- 启动票据校验。
- 本地 License/设备身份校验。
- 本地策略校验。
- Manifest 完整性校验。
- 健康标记写入。
真正接入业务软件时,把这些启动检查逻辑移植到业务主程序。用户入口应改成 `Launcher.exe`,不要让用户直接双击 `SimCAE.exe`
重要:业务主程序校验 ticket 时,必须使用 SDK 当前运行配置里的动态值,不要直接从 `app_config.json` 读取 `device_id`
原因是 `app_config.json` 是部署源文件,网页生成时 `device_id` 通常为空;真正的设备 ID 是 `Launcher.exe` 首次向服务端登记后写入当前用户注册表的。`Launcher.exe` 生成 ticket 时使用的是注册表里的真实 `device_id`。如果业务主程序从 `app_config.json` 读取空的 `device_id` 来校验,就会出现:
```text
ticket signature, identity, time or nonce invalid
```
或者细化后的:
```text
ticket device_id mismatch
```
业务主程序应像 `MainApp/main.cpp` 示例一样使用:
```cpp
ConfigHelper& config = ConfigHelper::instance();
TicketHelper::consumeAndVerify(
ticketFilePath,
config.getValue("App", "app_id"),
config.getValue("Update", "device_id"),
config.getValue("App", "current_version"),
config.getValue("App", "launch_token"),
&ticketError);
```
也就是说,接入业务主程序时不能只复制一小段 ticket 代码后自己解析 JSON;要么复用 SDK 的 `ConfigHelper` / `TicketHelper`,要么保证业务主程序读取到的 `app_id``device_id``current_version``launch_token``Launcher.exe` 完全来自同一套运行配置。
## 六、你:准备服务端数据
首次联调前,服务端至少要准备这些内容:
1. 创建应用,例如 `simcae`
2. 创建渠道,例如 `stable`
3. 创建 License。
4. 发布一个初始版本,例如 `1.0.0`
5. 生成客户端配置,并写入 `bin\config\app_config.json`。客户端下次启动时会自动同步到注册表。
6. 生成 qrc 服务端配置,并写入源码目录 `config\server_config.json` 后重新编译 SDK 程序。
为什么必须先发布初始版本:Launcher 启动业务主程序前会做 Manifest 完整性校验。这个 Manifest 是服务端发布版本时生成并签名的清单,用来证明当前本地文件属于一个可信版本。如果没有发布过 `current_version` 对应版本,客户端会提示签名 Manifest 缓存缺失。
后台发布版本时,选择整个安装根目录,例如 `D:\SimCAE\`,不要只选择 `D:\SimCAE\bin`。这样服务端会把 `bin/SimCAE.exe``Licenses/``installerResources/` 等完整结构写进 Manifest。
## 七、你:运行和联调
基础联调步骤:
1. 确认服务端正在运行。
2. 确认 `bin\config\app_config.json``client_token``license_key``current_version` 正确,并确认 `Launcher.exe` 已用正确的 `config/server_config.json` 重新编译。
3. 双击 `bin\Launcher.exe`
4. 首次启动时如果 `license_key` 为空,按弹窗输入后台创建的 License;SDK 会把它保存到注册表。
5. 成功进入业务主程序后,回到后台查看设备、升级日志、下载日志。
6. 在后台发布更高版本,例如从 `1.0.0` 发布到 `1.0.1`
7. 再次启动 `Launcher.exe`,验证升级、健康确认和回滚逻辑。
联调目录建议:
```text
D:\SimCAE_Release\
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
```
如果安装在 `C:\Program Files\...``D:\...` 或其他普通用户不一定可写的目录,SDK 的普通配置值会写入当前用户注册表,不需要修改 `app_config.json``client_identity.dat``local_state.json``version_policy.dat`、更新缓存和 Manifest 缓存也会写入当前用户数据目录,不再默认写到安装目录。
Windows 用户数据目录类似:`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\`。因此日常启动、首次授权、保存设备身份、保存策略、防回滚状态和下载缓存不应再触发管理员权限确认框。只有真正要替换安装目录里的 EXE/DLL 等程序文件时,才需要保证安装目录可写,或让安装器/Updater 具备相应权限。
## 八、维护者:生成某个产品的最终客户端包
SDK 是给接入方开发使用的。最终给用户安装或分发时,可以从已经联调过的 Release 目录生成最终客户端包。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-client.ps1 `
-SourceDir .\out\bin\Release `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
`package-client.ps1` 会检查:
- 配置文件必填字段是否完整。
- 主程序、Launcher、Updater、Bootstrap 是否存在。
- 是否混入 Debug DLL、PDB、ILK。
- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取:
`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache`
同时兼容旧版 Release 目录里的 `update\manifest_cache`
- 是否存在重复主程序。
生成结果:
```text
dist/
UpdateClient/
UpdateClient.zip
```
Linux 最终客户端包生成方式:
```bash
cd /home/laluo/project/update-client
bash ./scripts/package-client.sh \
--source-dir /path/to/SimCAE \
--config-file /path/to/SimCAE/bin/config/app_config.json \
--output-dir ./dist/UpdateClient-linux \
--archive ./dist/UpdateClient-linux.tar.gz
```
Linux 打包脚本会检查:
- `app_config.json` 必填字段是否完整。
- 主程序、Launcher、Updater、Bootstrap 是否存在。
- 是否混入 Debug 产物。
- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取:
`$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`
未设置 `XDG_DATA_HOME` 时通常是 `~/.local/share/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`
同时兼容旧版 Release 目录里的 `update/manifest_cache`
- 是否存在重复主程序。
Linux 下如果程序安装在 `/opt``/usr/local` 等普通用户不可写目录,升级器无法像 Windows UAC 那样自动提权修改安装目录。正式部署前建议二选一:
1. 把软件安装到当前用户有写权限的目录,例如用户 home 下的应用目录。
2. 由安装器创建专用目录和权限,让运行用户对软件目录有写入权限。
## 九、常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动保存配置/状态文件失败:如果目录不可写,SDK 会弹出管理员权限确认框;用户取消或当前账号没有管理员权限时仍会失败。
3. 首次启动设备登记失败:检查编译进 qrc 的 `config/server_config.json``client_token``license_key`、服务端 License 状态。
4. 提示 License 错误或过期:在后台确认 License 是否存在、是否被禁用或删除、是否超过最大设备数。
5. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
6. 提示 signed manifest cache missing:先在后台发布一次 `current_version` 对应版本,并让客户端拿到该版本 Manifest。
7. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
8. 发布失败提示主程序不在根目录:服务端 `.env``RELEASE_MAIN_EXECUTABLE` 要和平台匹配。Windows 通常是 `bin/SimCAE.exe`Linux 通常是 `bin/SimCAE`
9. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`
@@ -0,0 +1,175 @@
# 客户端编译环境和第三方依赖说明
`thirdparty/` 是本机依赖目录,已经被 `.gitignore` 忽略,不会提交到 Git。
当前客户端构建依赖:
1. Qt 5.15.2 或兼容的 Qt 5 版本
2. OpenSSL
Windows 下推荐使用 Qt 5.15.2 msvc2019_64 和 OpenSSL-Win64Linux 下使用系统安装的 Qt/OpenSSL 开发包。
先看结论:
- Windows:配置 Qt 环境变量,把 OpenSSL 复制到 `thirdparty/OpenSSL-Win64`
- Linux:用 apt 安装 Qt/OpenSSL 开发包。
- `thirdparty/` 只放本机依赖,不提交 Git。
## 1. Qt 配置
### Windows
Qt 路径由本机环境变量提供。你需要在 Windows 环境变量里配置 Qt 路径,让 CMake 的 `find_package(Qt5 ...)` 能找到 Qt。
推荐配置用户环境变量 `CMAKE_PREFIX_PATH`
```powershell
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
```
设置完成后,重新打开 PowerShell 或 Visual Studio。
如果只想对当前 PowerShell 窗口临时生效:
```powershell
$env:CMAKE_PREFIX_PATH="C:\Qt\5.15.2\msvc2019_64"
```
也可以配置更精确的 `Qt5_DIR`
```powershell
[Environment]::SetEnvironmentVariable("Qt5_DIR", "C:\Qt\5.15.2\msvc2019_64\lib\cmake\Qt5", "User")
```
`CMAKE_PREFIX_PATH``Qt5_DIR` 二选一即可,推荐使用 `CMAKE_PREFIX_PATH`
一般不需要把 `C:\Qt\5.15.2\msvc2019_64\bin` 加入 `Path`。项目构建后会通过 `windeployqt` 复制运行所需的 Qt DLL。
### Linux
Linux 下需要安装 Qt5 开发包,让 CMake 能找到 `Qt5::Core``Qt5::Network``Qt5::Gui``Qt5::Widgets`
Ubuntu/Debian 示例:
```bash
sudo apt update
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev-tools libssl-dev
```
如果 Qt 安装在自定义目录,可以临时设置:
```bash
export CMAKE_PREFIX_PATH=/path/to/Qt/5.x/gcc_64
```
## 2. OpenSSL 配置
### Windows
OpenSSL 默认放在:
```text
thirdparty/OpenSSL-Win64
```
推荐目录结构:
```text
thirdparty/
OpenSSL-Win64/
include/
openssl/
lib/
VC/
x64/
MD/
MDd/
```
复制命令示例:
```powershell
cd C:\Users\admin\Desktop\update-client
mkdir thirdparty
Copy-Item "C:\Program Files\OpenSSL-Win64" ".\thirdparty\OpenSSL-Win64" -Recurse
```
如果 OpenSSL 不放在 `thirdparty/`,可以在配置 CMake 时手动指定:
```powershell
cmake -S . -B out\build\x64-Debug `
-G "Visual Studio 17 2022" `
-A x64 `
-DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
```
### Linux
Linux 下 CMake 会通过 `find_package(OpenSSL REQUIRED)` 查找系统 OpenSSL。通常安装 `libssl-dev` 即可,不需要 `thirdparty/OpenSSL-Win64`
## 3. Windows 重新配置和编译
如果之前配置过 CMake,建议先删除旧缓存:
```powershell
cd C:\Users\admin\Desktop\update-client
Remove-Item out\build -Recurse -Force
```
重新配置:
```powershell
cmake -S . -B out\build\x64-Debug `
-G "Visual Studio 17 2022" `
-A x64
```
编译:
```powershell
cmake --build out\build\x64-Debug --config Debug
```
## 4. Linux 重新配置和编译
如果之前配置过 CMake,建议先删除旧缓存:
```bash
cd ~/project/update-client
rm -rf out/build/linux-x64-debug out/build/linux-x64-release
```
Debug 构建:
```bash
cmake --preset linux-x64-debug
cmake --build --preset linux-x64-debug
```
Release 构建:
```bash
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
```
Linux 构建时会自动使用 `config/app_config.linux.example.json` 作为输出目录里的默认 `config/app_config.json`,可执行程序名不带 `.exe`
## 5. 提交注意事项
不要提交下面这些内容:
```text
thirdparty/
.vs/
out/
build/
dist/
App/
*.exe
*.dll
*.lib
*.pdb
```
这些都属于本机依赖、构建产物或打包产物,不应该进 Git。
+16 -20
View File
@@ -3,20 +3,20 @@ project(LauncherDemo LANGUAGES C CXX)
# C++标准 # C++标准
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Qt 自动处理ui/moc/rcc # Qt 自动处理ui/moc/rcc
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)
# 依赖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
# 所有源码文件 main.cpp
set(SRC_LIST UpdateLogic.h
main.cpp UpdateLogic.cpp
UpdateLogic.h ${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
UpdateLogic.cpp ${CMAKE_SOURCE_DIR}/config/server_config.qrc
) )
# 生成可执行程序 # 生成可执行程序
add_executable(Launcher ${SRC_LIST}) add_executable(Launcher ${SRC_LIST})
if(WIN32) if(WIN32)
@@ -28,12 +28,8 @@ 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 文件夹(干净不乱) # 强制VS打开CMake文件夹时默认选中该可执行目标
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
# 强制VS打开CMake文件夹时默认选中该可执行目标
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# 编译完成自动执行windeployqt,复制Qt dll到exe目录 # 编译完成自动执行windeployqt,复制Qt dll到exe目录
if(WIN32) if(WIN32)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
+228 -102
View File
@@ -1,117 +1,243 @@
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QDebug> #include <QDebug>
#include <QJsonArray> #include <QJsonArray>
#include <QApplication> #include <QApplication>
#include "PolicyHelper.h" #include <QDir>
#include "LocalStateHelper.h" #include <QSaveFile>
#include "PolicyHelper.h"
#include "LocalStateHelper.h"
UpdateLogic::UpdateLogic(QObject* parent)
: QObject(parent)
{
ConfigHelper& cfg = ConfigHelper::instance();
m_serverAddr = cfg.getValue("Server", "api_base_url");
m_appId = cfg.getValue("App", "app_id");
m_curVer = cfg.getValue("App", "current_version");
m_channel = cfg.getValue("App", "channel");
// Debug print config
qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId;
}
void UpdateLogic::checkUpdate()
{
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty())
{
qDebug() << "Config incomplete, abort update check";
m_needUpdate = false;
return;
}
QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject body;
body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
{
qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code;
m_checkResp = resp;
m_networkOk = (code == 200);
if (code == 200)
{
const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy())
{
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false;
m_needUpdate = false;
return;
}
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState())
{
qDebug() << "Cannot persist online policy state";
m_networkOk = false;
m_needUpdate = false;
return;
}
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString();
qDebug() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
});
}
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
{
QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = ver;
body["version_id"] = verId;
QJsonArray files;
body["files"] = files;
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== File Download Url ==========";
qDebug() << resp["files"].toArray();
});
}
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir)
{
m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
m_error = QCoreApplication::translate("UpdateLogic",
"Current version manifest identity is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.")
.arg(appId, channel, version, QString::number(versionId));
return false;
}
QJsonObject response;
int statusCode = 0;
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = version;
body["version_id"] = versionId;
m_http.postRequest(m_serverAddr + "/api/v1/update/manifest", body,
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
});
UpdateLogic::UpdateLogic(QObject* parent) if (statusCode != 200) {
: QObject(parent) const QJsonValue detail = response.value(QStringLiteral("detail"));
{ const QString message = detail.isObject()
ConfigHelper& cfg = ConfigHelper::instance(); ? detail.toObject().value(QStringLiteral("msg")).toString()
m_serverAddr = cfg.getValue("Server", "api_base_url"); : detail.toString();
m_appId = cfg.getValue("App", "app_id"); m_error = QCoreApplication::translate("UpdateLogic",
m_curVer = cfg.getValue("App", "current_version"); "Cannot download signed manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. App: %2, channel: %3, version: %4, version id: %5.%6")
m_channel = cfg.getValue("App", "channel"); .arg(QString::number(statusCode), appId, channel, version, QString::number(versionId),
message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
// Debug print config return false;
qDebug() << "Read server addr:" << m_serverAddr; }
qDebug() << "Read app id:" << m_appId;
const QJsonObject manifest = response.value("manifest").toObject();
const QString manifestText = response.value("manifest_text").toString();
if (manifest.isEmpty() || manifestText.isEmpty()) {
m_error = QCoreApplication::translate("UpdateLogic",
"The signed manifest response for the current local version is incomplete. Stage: cache current version manifest. App: %1, channel: %2, version: %3, version id: %4.")
.arg(appId, channel, version, QString::number(versionId));
return false;
}
if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = QCoreApplication::translate("UpdateLogic",
"The signed manifest identity does not match the current local version. Stage: cache current version manifest. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate("UpdateLogic",
"Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.")
.arg(cacheDir);
return false;
}
QJsonObject wrapper;
wrapper["manifest"] = manifest;
wrapper["manifest_text"] = manifestText;
QSaveFile file(dir.filePath("manifest_" + version + ".json"));
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
m_error = QCoreApplication::translate("UpdateLogic",
"Cannot save signed manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
.arg(file.fileName(), file.errorString());
return false;
}
qDebug() << "Current version manifest cached to" << file.fileName();
return true;
} }
void UpdateLogic::checkUpdate() bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
{ {
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) m_error.clear();
{ if (m_serverAddr.isEmpty()) {
qDebug() << "Config incomplete, abort update check"; m_error = QCoreApplication::translate("UpdateLogic", "Server address is empty.");
m_needUpdate = false; return false;
return;
} }
QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject response;
int statusCode = 0;
QJsonObject body; QJsonObject body;
body["app_id"] = m_appId; body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel; body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt(); m_http.postRequest(m_serverAddr + "/api/v1/git/tags", body,
body["client_protocol"] = qMax(3, configuredProtocol); [&](int code, const QJsonObject& resp) {
statusCode = code;
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) response = resp;
{
qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code;
m_checkResp = resp;
m_networkOk = (code == 200);
if (code == 200)
{
const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy())
{
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false;
m_needUpdate = false;
return;
}
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState())
{
qDebug() << "Cannot persist online policy state";
m_networkOk = false;
m_needUpdate = false;
return;
}
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString();
qDebug() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
}); });
}
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) if (statusCode != 200) {
{ const QJsonValue detail = response.value("detail");
QString url = m_serverAddr + "/api/v1/update/download-url"; const QString message = detail.isObject()
QJsonObject body; ? detail.toObject().value("msg").toString()
body["app_id"] = appId; : detail.toString();
body["channel"] = channel; m_error = message.isEmpty()
body["version"] = ver; ? QCoreApplication::translate("UpdateLogic", "Git tags request failed (HTTP %1).").arg(statusCode)
body["version_id"] = verId; : message;
QJsonArray files; return false;
body["files"] = files; }
m_http.postRequest(url, body, [](int code, const QJsonObject& resp) const QString tagsText = response.value("tags_text").toString();
{ if (tagsText.isEmpty()) {
qDebug() << "\n========== File Download Url =========="; m_error = QCoreApplication::translate("UpdateLogic", "Git tags response is empty.");
qDebug() << resp["files"].toArray(); return false;
}); }
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(outputPath, tagsText.toUtf8(), &writeError)) {
m_error = QCoreApplication::translate("UpdateLogic", "Cannot write Git tags file: %1").arg(writeError);
return false;
}
qDebug() << "Git tags file refreshed:" << outputPath;
return true;
} }
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
{ {
QString url = m_serverAddr + "/api/v1/update/report"; QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body; QJsonObject body;
body["app_id"] = m_appId; body["app_id"] = m_appId;
body["device_id"] = deviceId; body["device_id"] = deviceId;
body["from_version"] = fromVer; body["from_version"] = fromVer;
body["to_version"] = toVer; body["to_version"] = toVer;
body["result"] = success ? "success" : "fail"; body["result"] = success ? "success" : "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp) m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{ {
qDebug() << "\n========== Update Report Result =========="; qDebug() << "\n========== Update Report Result ==========";
qDebug() << resp; qDebug() << resp;
}); });
} }
+18 -13
View File
@@ -10,15 +10,19 @@ class UpdateLogic : public QObject
public: public:
explicit UpdateLogic(QObject* parent = nullptr); explicit UpdateLogic(QObject* parent = nullptr);
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,
bool getNeedUpdate() const { return m_needUpdate; } int versionId, const QString& cacheDir);
QString getLatestVersion() const { return m_latestVer; } bool refreshGitTagsFile(const QString& outputPath);
QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; } bool getNeedUpdate() const { return m_needUpdate; }
int lastStatusCode() const { return m_lastStatusCode; } QString getLatestVersion() const { return m_latestVer; }
QJsonObject getCheckResult() const { return m_checkResp; }
bool isNetworkOk() const { return m_networkOk; }
int lastStatusCode() const { return m_lastStatusCode; }
QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; } QString getAppId() const { return m_appId; }
QString getChannel() const { return m_channel; } QString getChannel() const { return m_channel; }
@@ -32,7 +36,8 @@ private:
bool m_needUpdate = false; bool m_needUpdate = false;
bool m_networkOk = false; bool m_networkOk = false;
int m_lastStatusCode = 0; int m_lastStatusCode = 0;
QString m_latestVer; QString m_latestVer;
QJsonObject m_checkResp; QJsonObject m_checkResp;
}; QString m_error;
};
+304 -160
View File
@@ -1,282 +1,426 @@
#include <windows.h>
#include <QApplication> #include <QApplication>
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QInputDialog> #include <QInputDialog>
#include <QLineEdit> #include <QLineEdit>
#include <QTextCodec> #include <QTranslator>
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h" #include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h" #include "../Common/DeviceIdentityHelper.h"
#include <QFile> #include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QDir> #include <QDir>
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(65001);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher"); QApplication::setApplicationName("Marsco Launcher");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
QProgressDialog progress("正在检查软件更新...", QString(), 0, 0); const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
progress.setWindowTitle("Marsco 软件启动器"); if (elevatedWriteExitCode >= 0)
progress.setCancelButton(nullptr); return elevatedWriteExitCode;
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
progress.setAutoClose(false);
progress.show();
QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath(); QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher"));
progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
progress.setAutoClose(false);
progress.show();
QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) { const auto isLicenseError = [](const QString& errorText) {
const QString text = errorText.toLower(); const QString text = errorText.toLower();
return text.contains("license") return text.contains("license")
|| errorText.contains("授权") || text.contains("authorization")
|| errorText.contains("过期") || text.contains("expired")
|| errorText.contains("设备数量已达到上限") || text.contains("device limit")
|| errorText.contains("已绑定其他授权"); || text.contains("bound to another license");
}; };
const auto clearDeviceCredential = [&]() { const auto clearDeviceCredential = [&]() {
QFile::remove(QDir(appDir).filePath("config/client_identity.dat")); ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath());
config.setValue("Update", "device_id", QString()); config.setValue("Update", "device_id", QString());
}; };
QString licenseKey = config.getValue("License", "license_key").trimmed(); QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString& reason) -> QString { auto promptAndSaveLicense = [&](const QString& reason) -> QString {
QString promptReason = reason; QString promptReason = reason;
while (true) { while (true) {
progress.close(); progress.close();
bool accepted = false; bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed(); const QString appName = config.getValue("App", "app_name").trimmed();
const QString prompt = promptReason.trimmed().isEmpty() const QString prompt = promptReason.trimmed().isEmpty()
? QString("请输入%1授权 License").arg(appName.isEmpty() ? "软件" : appName) ? QCoreApplication::translate("Launcher", "Please enter the License for %1:")
: QString("%1\n\n请重新输入%2授权 License").arg(promptReason, appName.isEmpty() ? "软件" : appName); .arg(appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName)
: QCoreApplication::translate("Launcher", "%1\n\nPlease enter a new License for %2:")
.arg(promptReason, appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName);
licenseKey = QInputDialog::getText( licenseKey = QInputDialog::getText(
nullptr, nullptr,
"输入 License", QCoreApplication::translate("Launcher", "Enter License"),
prompt, prompt,
QLineEdit::Normal, QLineEdit::Normal,
QString(), QString(),
&accepted &accepted
).trimmed(); ).trimmed();
if (!accepted) { if (!accepted) {
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。"); QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Required"),
QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch."));
return QString(); return QString();
} }
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty()) {
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。"); QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
QCoreApplication::translate("Launcher", "Please paste the License created in the admin page."));
promptReason.clear(); promptReason.clear();
continue; continue;
} }
if (!config.setValue("License", "license_key", licenseKey)) { if (!config.setValue("License", "license_key", licenseKey)) {
QMessageBox::critical(nullptr, "保存 License 失败", QMessageBox::critical(nullptr,
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError())); QCoreApplication::translate("Launcher", "Failed to Save License"),
QCoreApplication::translate("Launcher", "Cannot write the configuration file:\n%1\n%2").arg(config.configPath(), config.lastError()));
return QString(); return QString();
} }
progress.show(); progress.show();
progress.setLabelText("正在验证 License..."); progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License..."));
QApplication::processEvents(); QApplication::processEvents();
return licenseKey; return licenseKey;
} }
}; };
while (true) { while (true) {
// License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。
// 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty()) {
licenseKey = promptAndSaveLicense(QString()); licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty()) return 0;
} }
DeviceIdentityHelper identity(appDir); DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"), if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"), config.getValue("Server", "client_token"),
config.getValue("App", "app_id"), config.getValue("App", "app_id"),
config.getValue("App", "channel"), config.getValue("App", "channel"),
licenseKey)) { licenseKey)) {
break; break;
} }
const QString error = identity.errorString(); const QString error = identity.errorString();
if (!isLicenseError(error)) { if (!isLicenseError(error)) {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "设备身份验证失败", error); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Device Authentication Failed"),
error);
return -1; return -1;
} }
clearDeviceCredential(); clearDeviceCredential();
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error)); licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error));
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty()) return 0;
} }
UpdateLogic logic; UpdateLogic logic;
logic.checkUpdate(); logic.checkUpdate();
const bool needUpdate = logic.getNeedUpdate(); const bool needUpdate = logic.getNeedUpdate();
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 int targetVersionId = response.value("version_id").toInt();
const QString appId = logic.getAppId(); const QString appId = logic.getAppId();
const QString channel = logic.getChannel(); const QString channel = logic.getChannel();
const QString launchToken = config.getValue("App", "launch_token");
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { const QString currentVersion = config.getValue("App", "current_version");
progress.close();
const QJsonValue detail = response.value("detail"); if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); progress.close();
const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message; const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty()
? QCoreApplication::translate("Launcher", "The device or License authorization is invalid.")
: message;
if (isLicenseError(displayMessage)) { if (isLicenseError(displayMessage)) {
clearDeviceCredential(); clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage)); const QString newLicense = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current authorization was rejected by the server: %1").arg(displayMessage));
if (!newLicense.isEmpty()) { if (!newLicense.isEmpty()) {
progress.close(); progress.close();
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。"); QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Saved"),
QCoreApplication::translate("Launcher", "Please restart Launcher to complete device authorization and update checking."));
} }
return 0; return 0;
} }
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage); QMessageBox::critical(nullptr,
return -1; QCoreApplication::translate("Launcher", "Authorization Rejected"),
} 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("Runtime", key), fallback);
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString mainAppPath = QDir(appDir).filePath(mainExecutable); const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable); const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
const auto importOfflinePackage = [&]() { const auto importOfflinePackage = [&]() {
const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)"); const QString package = QFileDialog::getOpenFileName(nullptr,
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)}); QCoreApplication::translate("Launcher", "Select Offline Update Package"),
QString(),
QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)"));
if (package.isEmpty())
return false;
if (!QFileInfo::exists(updaterPath)) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot import the offline update package because the updater executable does not exist.\nUpdater: %1\nOffline package: %2")
.arg(updaterPath, package));
return false;
}
if (!QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)})) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater for offline package import.\nUpdater: %1\nOffline package: %2\nCheck file permissions and dependent DLLs/shared libraries.")
.arg(updaterPath, package));
return false;
}
return true;
}; };
const QString deviceId = config.getValue("Update", "device_id"); const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) { if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close(); progress.close();
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。"); if (!importOfflinePackage())
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected."));
return 0; return 0;
} }
QString mainStartupError;
const auto startMainApp = [&]() { const auto startMainApp = [&]() {
// 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。
// 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) { launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false; return false;
} }
const bool started = QProcess::startDetached(mainAppPath, const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)}); QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) 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("正在验证本地运行策略..."); progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy..."));
QApplication::processEvents(); QApplication::processEvents();
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString())); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
return -1; return -1;
} }
if (policy.isExpired()) if (policy.isExpired())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "The local version policy has expired. Please connect to the network or contact the administrator."));
return -1; return -1;
} }
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "当前版本不可运行", QMessageBox::critical(nullptr,
policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion) QCoreApplication::translate("Launcher", "Current Version Cannot Run"),
policy.message().isEmpty() ? QCoreApplication::translate("Launcher", "The current version %1 has been disabled by the administrator.").arg(currentVersion)
: policy.message()); : policy.message());
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq()))
LocalStateHelper state(appDir); {
if (!state.loadState()) progress.close();
{ QMessageBox::critical(nullptr,
progress.close(); QCoreApplication::translate("Launcher", "Security Check Failed"),
QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString())); QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected. Startup has been blocked."));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。");
return -1;
}
if (state.isSystemTimeRewound()) if (state.isSystemTimeRewound())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A possible system time rollback was detected. Startup has been blocked."));
return -1; return -1;
} }
if (networkOk && policy.gitTagsEnabled())
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Generating Git tag list..."));
QApplication::processEvents();
const QString tagsPath = QDir(appDir).filePath("tags.txt");
if (!logic.refreshGitTagsFile(tagsPath))
{
progress.close();
QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "Git Tag List Failed"),
QCoreApplication::translate("Launcher", "Cannot generate tags.txt, but startup will continue:\n%1").arg(logic.errorString()));
progress.show();
QApplication::processEvents();
}
}
if (networkOk && needUpdate) if (networkOk && needUpdate)
{ {
progress.close(); progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
const QString dialogTitle = rollbackOperation ? "版本回退" const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback")
: (policy.forceUpdate() ? "必须更新" : "发现新版本"); : (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available"));
const QString prompt = policy.message().isEmpty() const QString prompt = policy.message().isEmpty()
? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?" ? (rollbackOperation
: "发现新版本 %1,是否现在更新?").arg(latestVer) ? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer)
: policy.message() + QString("\n目标版本:%1").arg(latestVer); : QCoreApplication::translate("Launcher", "Version %1 is available. Update now?").arg(latestVer))
bool accepted = true; : policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
if (policy.forceUpdate()) { bool accepted = true;
QMessageBox::information(nullptr, dialogTitle, prompt); if (policy.forceUpdate()) {
} else { QMessageBox::information(nullptr, dialogTitle, prompt);
accepted = QMessageBox::question(nullptr, dialogTitle, prompt, } else {
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; 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, QString::number(targetVersionId)};
if (!QFileInfo::exists(updaterPath))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater because the executable file does not exist.\nExecutable: %1\nCheck updater_executable and install_root in the generated client configuration.")
.arg(updaterPath));
return -1;
}
if (!QProcess::startDetached(updaterPath, updaterArgs)) if (!QProcess::startDetached(updaterPath, updaterArgs))
{ {
QMessageBox::critical(nullptr, "更新器启动失败", 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) { if (networkOk && !needUpdate && targetVersionId > 0 && latestVer == currentVersion)
progress.close(); {
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", progress.setLabelText(QCoreApplication::translate("Launcher", "Caching signed version manifest..."));
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { QApplication::processEvents();
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。"); const QString manifestCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("manifest_cache");
return 0; if (!logic.cacheManifest(appId, channel, currentVersion, targetVersionId, manifestCacheDir))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Manifest Cache Failed"),
QCoreApplication::translate("Launcher", "Cannot cache the signed manifest for the current version: %1").arg(logic.errorString()));
return -1;
} }
progress.show();
} }
if (!networkOk) {
progress.close();
if (QMessageBox::question(nullptr,
QCoreApplication::translate("Launcher", "Server Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server. Import an offline update package?"),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
if (!importOfflinePackage())
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started."));
return 0;
}
progress.show();
}
if (!networkOk && !policy.isOfflineAllowed()) if (!networkOk && !policy.isOfflineAllowed())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Network Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server, and the current policy does not allow offline startup."));
return -1; return -1;
} }
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); progress.setLabelText(networkOk
QApplication::processEvents(); ? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
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();
return 0; return 0;
} }
+21 -19
View File
@@ -1,21 +1,23 @@
project(MainApp) project(MainApp)
add_executable(MainApp add_executable(MainApp
main.cpp main.cpp
MainWindow.h MainWindow.h
MainWindow.cpp MainWindow.cpp
../Common/ConfigHelper.h ../Common/ConfigHelper.h
) ${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
target_include_directories(MainApp ${CMAKE_SOURCE_DIR}/config/server_config.qrc
PUBLIC ${CMAKE_SOURCE_DIR}/Common )
PRIVATE ${OPENSSL_INC} target_include_directories(MainApp
) PUBLIC ${CMAKE_SOURCE_DIR}/Common
target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets) PRIVATE ${OPENSSL_INC}
)
if(WIN32) target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) if(WIN32)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
add_custom_command(TARGET MainApp POST_BUILD get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
add_custom_command(TARGET MainApp POST_BUILD
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp> COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
) )
endif() endif()
+128 -123
View File
@@ -1,130 +1,135 @@
#include <Windows.h> #include <QApplication>
#include <QApplication> #include <QDebug>
#include <QDebug> #include <QMessageBox>
#include <QTextCodec> #include <QDir>
#include <QMessageBox> #include <QFileInfo>
#include <QDir> #include <QSaveFile>
#include <QFileInfo> #include <QTranslator>
#include <QSaveFile> #include "MainWindow.h"
#include "MainWindow.h" #include "../Common/ConfigHelper.h"
#include "../Common/ConfigHelper.h" #include "../Common/PolicyHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/LocalStateHelper.h"
#include "../Common/LocalStateHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/IntegrityHelper.h"
#include "../Common/IntegrityHelper.h" #include "../Common/DeviceIdentityHelper.h"
#include "../Common/DeviceIdentityHelper.h"
int main(int argc, char* argv[])
int main(int argc, char* argv[]) {
{ QApplication a(argc, argv);
SetConsoleOutputCP(936); QTranslator translator;
QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK")); if (translator.load(":/i18n/update-client_zh_CN.qm"))
a.installTranslator(&translator);
QApplication a(argc, argv);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
qDebug() << Qt::endl << "entered main app" << Qt::endl; if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
qDebug() << Qt::endl << "entered main app" << Qt::endl;
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher");
QString ticketFilePath; QString ticketFilePath;
QString healthFilePath; QString healthFilePath;
for (int i = 1; i < argc; ++i) for (int i = 1; i < argc; ++i)
{ {
const QString arg(argv[i]); const QString arg(argv[i]);
if (arg.startsWith("--ticket-file=")) if (arg.startsWith("--ticket-file="))
ticketFilePath = arg.mid(QString("--ticket-file=").size()); ticketFilePath = arg.mid(QString("--ticket-file=").size());
else if (arg.startsWith("--health-file=")) else if (arg.startsWith("--health-file="))
healthFilePath = arg.mid(QString("--health-file=").size()); healthFilePath = arg.mid(QString("--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"), config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"), config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
&ticketError)) &ticketError))
{ {
QMessageBox::critical(nullptr, "Startup Restriction", QMessageBox::critical(nullptr, "Startup Restriction",
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.")
.arg(ticketError, launcherExecutable)); .arg(ticketError, launcherExecutable));
return -1; return -1;
} }
QString appDir = QApplication::applicationDirPath(); QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot(); const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir); DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
{ {
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString())); QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
return -1; return -1;
} }
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{ {
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString())); QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
return -1; return -1;
} }
if (policy.isExpired()) if (policy.isExpired())
{ {
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application."); QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
return -1; return -1;
} }
LocalStateHelper state(appDir); LocalStateHelper state(appDir);
if (!state.loadState()) if (!state.loadState())
{ {
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString())); QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq())) if (state.isPolicySeqRolledBack(policy.policySeq()))
{ {
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked."); QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
return -1; return -1;
} }
if (state.isSystemTimeRewound()) if (state.isSystemTimeRewound())
{ {
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked."); QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
return -1; return -1;
} }
IntegrityHelper integrity(installRoot); IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion( if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"), config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version"))) config.getValue("App", "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 signed Manifest verification.\n"
"This is not a download check; it means the current installation directory does not match the signed Manifest cache for this version.\n\n"
"Details:\n%1")
.arg(integrity.errorString())); .arg(integrity.errorString()));
return -1; return -1;
} }
MainWindow w; MainWindow w;
w.show(); w.show();
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq()); state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState()) if (!state.saveState())
{ {
QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString())); QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (!healthFilePath.isEmpty()) if (!healthFilePath.isEmpty())
{ {
QDir().mkpath(QFileInfo(healthFilePath).path()); QDir().mkpath(QFileInfo(healthFilePath).path());
QSaveFile healthFile(healthFilePath); QSaveFile healthFile(healthFilePath);
const QByteArray healthy("ok\n"); const QByteArray healthy("ok\n");
if (!healthFile.open(QIODevice::WriteOnly) if (!healthFile.open(QIODevice::WriteOnly)
|| healthFile.write(healthy) != healthy.size() || healthFile.write(healthy) != healthy.size()
|| !healthFile.commit()) || !healthFile.commit())
{ {
QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file."); QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file.");
return -1; return -1;
} }
} }
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`
+36 -35
View File
@@ -1,41 +1,42 @@
# 强制所有编译、设计时生成均使用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
main.cpp main.cpp
UpdaterLogic.h UpdaterLogic.h
UpdaterLogic.cpp UpdaterLogic.cpp
UpdateTransaction.h UpdateTransaction.h
UpdateTransaction.cpp UpdateTransaction.cpp
) ${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
add_executable(Updater ${SRC}) ${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
if(WIN32) add_executable(Updater ${SRC})
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
endif() if(WIN32)
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到 endif()
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
# 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递 target_include_directories(Updater PRIVATE ${OPENSSL_INC})
target_link_libraries(Updater PRIVATE
Common # 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets target_link_libraries(Updater PRIVATE
) Common
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
# Qt部署脚本 )
if(WIN32)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) # Qt部署脚本
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) if(WIN32)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
add_custom_command(TARGET Updater POST_BUILD get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater> set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
) add_custom_command(TARGET Updater POST_BUILD
endif() COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
)
endif()
+268 -261
View File
@@ -1,274 +1,281 @@
#include "UpdateTransaction.h" #include "UpdateTransaction.h"
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <QSet> #include <QSet>
#include <QUuid> #include <QUuid>
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId) const QString& toVersion, int manifestId)
: m_installDir(QDir::cleanPath(installDir)), : m_installDir(QDir::cleanPath(installDir)),
m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)), m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)),
m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")), m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")),
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)), m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId) m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
{ {
m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId); m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId);
m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId); m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId);
} }
bool UpdateTransaction::safeRelativePath(const QString& path) bool UpdateTransaction::safeRelativePath(const QString& path)
{ {
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":"); && !clean.startsWith("../") && !clean.contains(":");
} }
bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const
{ {
if (!QDir().mkpath(QFileInfo(destination).path())) return false; if (!QDir().mkpath(QFileInfo(destination).path())) return false;
if (QFile::exists(destination) && !QFile::remove(destination)) return false; if (QFile::exists(destination) && !QFile::remove(destination)) return false;
return QFile::copy(source, destination); return QFile::copy(source, destination);
} }
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;
m_state["status"] = status; m_state["status"] = status;
m_state["manifest_id"] = m_manifestId; m_state["manifest_id"] = m_manifestId;
m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["staging_dir"] = m_stagingDir; m_state["staging_dir"] = m_stagingDir;
m_state["backup_dir"] = m_backupDir; m_state["backup_dir"] = m_backupDir;
m_state["error_code"] = errorCode; m_state["error_code"] = errorCode;
m_state["message"] = message; m_state["message"] = message;
QJsonArray paths; QJsonArray paths;
for (const QString& path : m_changedPaths) paths.append(path); for (const QString& path : m_changedPaths) paths.append(path);
m_state["changed_paths"] = paths; m_state["changed_paths"] = paths;
QJsonArray obsoletePaths; QJsonArray obsoletePaths;
for (const QString& path : m_obsoletePaths) obsoletePaths.append(path); for (const QString& path : m_obsoletePaths) obsoletePaths.append(path);
m_state["obsolete_paths"] = obsoletePaths; m_state["obsolete_paths"] = obsoletePaths;
QDir().mkpath(m_updateDir); QDir().mkpath(m_updateDir);
QSaveFile file(m_stateFile); QSaveFile file(m_stateFile);
if (!file.open(QIODevice::WriteOnly)) return false; if (!file.open(QIODevice::WriteOnly)) return false;
const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented); const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented);
if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; } if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; }
return file.commit(); return file.commit();
} }
bool UpdateTransaction::initialize() bool UpdateTransaction::initialize()
{ {
QDir(m_stagingDir).removeRecursively(); QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively(); QDir(m_backupDir).removeRecursively();
if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false; if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false;
m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
return writeState("prepared"); return writeState("prepared");
} }
bool UpdateTransaction::resumeExisting(QString* errorMessage) bool UpdateTransaction::resumeExisting(QString* errorMessage)
{ {
QFile file(m_stateFile); QFile file(m_stateFile);
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; if (errorMessage) *errorMessage = "cannot open upgrade_state.json";
return false; return false;
} }
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close(); file.close();
if (!doc.isObject()) { if (!doc.isObject()) {
if (errorMessage) *errorMessage = "invalid upgrade_state.json"; if (errorMessage) *errorMessage = "invalid upgrade_state.json";
return false; return false;
} }
m_state = doc.object(); m_state = doc.object();
const QString expectedToVersion = m_toVersion; const QString expectedToVersion = m_toVersion;
const int expectedManifestId = m_manifestId; const int expectedManifestId = m_manifestId;
const QString stateStatus = m_state.value("status").toString(); const QString stateStatus = m_state.value("status").toString();
m_transactionId = m_state.value("transaction_id").toString(); m_transactionId = m_state.value("transaction_id").toString();
m_fromVersion = m_state.value("from_version").toString(); m_fromVersion = m_state.value("from_version").toString();
m_toVersion = m_state.value("to_version").toString(); m_toVersion = m_state.value("to_version").toString();
m_manifestId = m_state.value("manifest_id").toInt(); m_manifestId = m_state.value("manifest_id").toInt();
if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId
|| (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify" || (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify"
&& stateStatus != "rollback_required")) { && stateStatus != "rollback_required")) {
if (errorMessage) *errorMessage = "upgrade state does not match resume request"; if (errorMessage) *errorMessage = "upgrade state does not match resume request";
return false; return false;
} }
m_stagingDir = m_state.value("staging_dir").toString(); m_stagingDir = m_state.value("staging_dir").toString();
m_backupDir = m_state.value("backup_dir").toString(); m_backupDir = m_state.value("backup_dir").toString();
m_changedPaths.clear(); m_changedPaths.clear();
m_obsoletePaths.clear(); m_obsoletePaths.clear();
for (const QJsonValue& value : m_state.value("changed_paths").toArray()) { for (const QJsonValue& value : m_state.value("changed_paths").toArray()) {
const QString path = value.toString(); const QString path = value.toString();
if (!safeRelativePath(path)) { if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe path in upgrade state"; if (errorMessage) *errorMessage = "unsafe path in upgrade state";
return false; return false;
} }
m_changedPaths.append(path); m_changedPaths.append(path);
} }
for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) { for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) {
const QString path = value.toString(); const QString path = value.toString();
if (!safeRelativePath(path)) { if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state"; if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state";
return false; return false;
} }
m_obsoletePaths.append(path); m_obsoletePaths.append(path);
} }
if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) { if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) {
if (errorMessage) *errorMessage = "incomplete upgrade state"; if (errorMessage) *errorMessage = "incomplete upgrade state";
return false; return false;
} }
return true; return true;
} }
bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths, bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
const QStringList& obsoletePaths) const QStringList& obsoletePaths)
{ {
m_changedPaths.clear(); m_changedPaths.clear();
m_obsoletePaths.clear(); m_obsoletePaths.clear();
QSet<QString> changedKeys; QSet<QString> changedKeys;
for (const QString& path : changedPaths) { for (const QString& path : changedPaths) {
if (!safeRelativePath(path)) return false; if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path); const QString normalized = QDir::fromNativeSeparators(path);
changedKeys.insert(normalized.toCaseFolded()); changedKeys.insert(normalized.toCaseFolded());
m_changedPaths.append(normalized); m_changedPaths.append(normalized);
} }
for (const QString& path : obsoletePaths) { for (const QString& path : obsoletePaths) {
if (!safeRelativePath(path)) return false; if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path); const QString normalized = QDir::fromNativeSeparators(path);
if (changedKeys.contains(normalized.toCaseFolded())) return false; if (changedKeys.contains(normalized.toCaseFolded())) return false;
m_obsoletePaths.append(normalized); m_obsoletePaths.append(normalized);
} }
return writeState("verified"); return writeState("verified");
} }
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);
for (const QString& path : paths) { for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path); const QString installed = QDir(m_installDir).filePath(path);
if (!QFile::exists(installed)) continue; if (!QFile::exists(installed)) continue;
const QString backup = QDir(m_backupDir).filePath(path); const QString backup = QDir(m_backupDir).filePath(path);
if (!copyOverwrite(installed, backup)) if (!copyOverwrite(installed, backup))
return markFailed("backup_failed", path); return markFailed("backup_failed", path);
} }
return writeState("backed_up"); return writeState("backed_up");
} }
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);
const QString destination = QDir(m_installDir).filePath(path); const QString destination = QDir(m_installDir).filePath(path);
if (!copyOverwrite(source, destination)) { if (!copyOverwrite(source, destination)) {
if (failedPath) *failedPath = path; if (failedPath) *failedPath = path;
writeState("rollback_required", "replace_failed", path); writeState("rollback_required", "replace_failed", path);
return false; return false;
} }
} }
return writeState("replaced"); return writeState("replaced");
} }
bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); } bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); }
bool UpdateTransaction::markRollbackRequired(const QString& reason) bool UpdateTransaction::markRollbackRequired(const QString& reason)
{ return writeState("rollback_required", "post_install_failed", reason); } { return writeState("rollback_required", "post_install_failed", reason); }
bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); } bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); }
bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); } bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); }
bool UpdateTransaction::rollback(QString* failedPath) bool UpdateTransaction::rollback(QString* failedPath)
{ {
writeState("rolling_back"); writeState("rolling_back");
bool ok = true; bool ok = true;
QStringList paths = m_changedPaths; QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths); paths.append(m_obsoletePaths);
for (const QString& path : paths) { for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path); const QString installed = QDir(m_installDir).filePath(path);
const QString backup = QDir(m_backupDir).filePath(path); const QString backup = QDir(m_backupDir).filePath(path);
if (QFile::exists(backup)) { if (QFile::exists(backup)) {
if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; } if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; }
} else if (QFile::exists(installed) && !QFile::remove(installed)) { } else if (QFile::exists(installed) && !QFile::remove(installed)) {
ok = false; if (failedPath) *failedPath = path; ok = false; if (failedPath) *failedPath = path;
} }
} }
writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed", writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed",
failedPath ? *failedPath : QString()); failedPath ? *failedPath : QString());
return ok; return ok;
} }
bool UpdateTransaction::commit() bool UpdateTransaction::commit()
{ {
if (!writeState("committed")) return false; if (!writeState("committed")) return false;
QDir(m_stagingDir).removeRecursively(); QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively(); QDir(m_backupDir).removeRecursively();
return true; return true;
} }
bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message) bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message)
{ return writeState("failed", errorCode, message); } { return writeState("failed", errorCode, message); }
QString UpdateTransaction::transactionId() const { return m_transactionId; } QString UpdateTransaction::transactionId() const { return m_transactionId; }
QString UpdateTransaction::fromVersion() const { return m_fromVersion; } QString UpdateTransaction::fromVersion() const { return m_fromVersion; }
QString UpdateTransaction::stagingDir() const { return m_stagingDir; } QString UpdateTransaction::stagingDir() const { return m_stagingDir; }
QString UpdateTransaction::backupDir() const { return m_backupDir; } QString UpdateTransaction::backupDir() const { return m_backupDir; }
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; } QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); } QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
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");
if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile)) if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile))
stateFile = legacyStateFile; stateFile = legacyStateFile;
QFile file(stateFile); QFile file(stateFile);
if (!file.exists()) return true; if (!file.exists()) return true;
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; } if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close(); file.close();
if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; } if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; }
QJsonObject state = doc.object(); QJsonObject state = doc.object();
const QString status = state.value("status").toString(); const QString status = state.value("status").toString();
if (status == "committed") return true; if (status == "committed") return true;
if (status == "rolled_back") { if (status == "rolled_back") {
if (restoredVersion) *restoredVersion = state.value("from_version").toString(); if (restoredVersion) *restoredVersion = state.value("from_version").toString();
return true; return true;
} }
const QString backupDir = state.value("backup_dir").toString(); const QString backupDir = state.value("backup_dir").toString();
QJsonArray paths = state.value("changed_paths").toArray(); QJsonArray paths = state.value("changed_paths").toArray();
for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value); for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value);
const QString errorCode = state.value("error_code").toString(); const QString errorCode = state.value("error_code").toString();
const bool mayContainNewFiles = status == "replacing" || status == "replaced" const bool mayContainNewFiles = status == "replacing" || status == "replaced"
|| status == "post_verify" || status == "rollback_required" || status == "post_verify" || status == "rollback_required"
|| status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed"; || status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed";
bool ok = true; bool ok = true;
for (const QJsonValue& value : paths) { for (const QJsonValue& value : paths) {
const QString path = value.toString(); const QString path = value.toString();
if (!safeRelativePath(path)) { ok = false; continue; } if (!safeRelativePath(path)) { ok = false; continue; }
const QString installed = QDir(installDir).filePath(path); const QString installed = QDir(installDir).filePath(path);
const QString backup = QDir(backupDir).filePath(path); const QString backup = QDir(backupDir).filePath(path);
if (QFile::exists(backup)) { if (QFile::exists(backup)) {
QDir().mkpath(QFileInfo(installed).path()); QDir().mkpath(QFileInfo(installed).path());
if (QFile::exists(installed)) QFile::remove(installed); if (QFile::exists(installed)) QFile::remove(installed);
if (!QFile::copy(backup, installed)) ok = false; if (!QFile::copy(backup, installed)) ok = false;
} else if (mayContainNewFiles) { } else if (mayContainNewFiles) {
if (QFile::exists(installed) && !QFile::remove(installed)) ok = false; if (QFile::exists(installed) && !QFile::remove(installed)) ok = false;
} }
} }
if (restoredVersion) *restoredVersion = state.value("from_version").toString(); if (restoredVersion) *restoredVersion = state.value("from_version").toString();
state["status"] = ok ? "rolled_back" : "failed"; state["status"] = ok ? "rolled_back" : "failed";
QSaveFile output(stateFile); QSaveFile output(stateFile);
if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); } if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); }
if (!ok && errorMessage) *errorMessage = "failed to restore one or more files"; if (!ok && errorMessage) *errorMessage = "failed to restore one or more files";
return ok; return ok;
} }
+54 -54
View File
@@ -1,54 +1,54 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QJsonObject> #include <QJsonObject>
class UpdateTransaction class UpdateTransaction
{ {
public: public:
UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId); const QString& toVersion, int manifestId);
bool initialize(); bool initialize();
bool resumeExisting(QString* errorMessage = nullptr); bool resumeExisting(QString* errorMessage = nullptr);
bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths); bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths);
bool backupCurrentFiles(); bool backupCurrentFiles();
bool installStagedFiles(QString* failedPath = nullptr); bool installStagedFiles(QString* failedPath = nullptr);
bool markAwaitingBootstrap(); bool markAwaitingBootstrap();
bool markRollbackRequired(const QString& reason); bool markRollbackRequired(const QString& reason);
bool markRolledBack(); bool markRolledBack();
bool markPostVerify(); bool markPostVerify();
bool rollback(QString* failedPath = nullptr); bool rollback(QString* failedPath = nullptr);
bool commit(); bool commit();
bool markFailed(const QString& errorCode, const QString& message); bool markFailed(const QString& errorCode, const QString& message);
QString transactionId() const; QString transactionId() const;
QString fromVersion() const; QString fromVersion() const;
QString stagingDir() const; QString stagingDir() const;
QString backupDir() const; QString backupDir() const;
QStringList obsoletePaths() const; QStringList obsoletePaths() const;
QString healthFile() const; QString healthFile() const;
static bool recoverInterrupted(const QString& installDir, const QString& updateDir, static bool recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion = nullptr, QString* restoredVersion = nullptr,
QString* errorMessage = nullptr); QString* errorMessage = nullptr);
private: private:
bool writeState(const QString& status, const QString& errorCode = QString(), bool writeState(const QString& status, const QString& errorCode = QString(),
const QString& message = QString()); const QString& message = QString());
bool copyOverwrite(const QString& source, const QString& destination) const; bool copyOverwrite(const QString& source, const QString& destination) const;
static bool safeRelativePath(const QString& path); static bool safeRelativePath(const QString& path);
QString m_installDir; QString m_installDir;
QString m_updateDir; QString m_updateDir;
QString m_stateFile; QString m_stateFile;
QString m_transactionId; QString m_transactionId;
QString m_fromVersion; QString m_fromVersion;
QString m_toVersion; QString m_toVersion;
int m_manifestId = 0; int m_manifestId = 0;
QString m_stagingDir; QString m_stagingDir;
QString m_backupDir; QString m_backupDir;
QStringList m_changedPaths; QStringList m_changedPaths;
QStringList m_obsoletePaths; QStringList m_obsoletePaths;
QJsonObject m_state; QJsonObject m_state;
}; };
+687 -516
View File
File diff suppressed because it is too large Load Diff
+69 -67
View File
@@ -1,73 +1,75 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include "HttpHelper.h" #include "HttpHelper.h"
#include "FileHelper.h" #include "FileHelper.h"
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QStringList> #include <QStringList>
#include <QNetworkReply> #include <QNetworkReply>
#include <QMap> #include <QMap>
#include <QElapsedTimer> #include <QElapsedTimer>
struct FileDownloadItem struct FileDownloadItem
{ {
QString path; QString path;
QString url; QString url;
QString sha256; QString sha256;
qint64 size; qint64 size;
}; };
class UpdaterLogic : public QObject class UpdaterLogic : public QObject
{ {
Q_OBJECT Q_OBJECT
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);
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 reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success); void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
bool downloadAllFiles(const QString& tempDir, const QString& targetDir); void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success);
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const; bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
QString calcLocalFileSha256(const QString& filePath) const; qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
QString calcLocalFileSha256(const QString& filePath) const;
QList<FileDownloadItem> getFileList() const;
QJsonObject getManifest() const { return m_manifest; } QList<FileDownloadItem> getFileList() const;
QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const; QJsonObject getManifest() const { return m_manifest; }
QString getManifestText() const { return m_manifestText; } QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const;
bool allDownloadSuccess() const { return m_downloadAllOk; } QString getManifestText() const { return m_manifestText; }
bool allDownloadSuccess() const { return m_downloadAllOk; }
signals:
void fetchUrlFinished(); signals:
void downloadProgress(qint64 receivedBytes, qint64 totalBytes, void fetchUrlFinished();
const QString& currentPath, double bytesPerSecond); void downloadProgress(qint64 receivedBytes, qint64 totalBytes,
const QString& currentPath, double bytesPerSecond);
private:
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256, private:
qint64 expectedSize, const QString& resumePartPath); bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
bool isSafeRelativePath(const QString& path) const; qint64 expectedSize, const QString& resumePartPath);
bool isRuntimeProtectedPath(const QString& path) const; bool isSafeRelativePath(const QString& path) const;
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const; bool isRuntimeProtectedPath(const QString& path) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const; QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const;
HttpHelper m_http;
QString m_serverAddr; HttpHelper m_http;
QJsonObject m_manifest; QString m_serverAddr;
QString m_manifestText; QJsonObject m_manifest;
QList<FileDownloadItem> m_fileItems; QString m_manifestText;
QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false; bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0; qint64 m_downloadTotalBytes = 0;
qint64 m_downloadCompletedBytes = 0; qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0; qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath; QString m_currentDownloadPath;
QString m_offlineError; QString m_offlineError;
mutable QString m_error;
QElapsedTimer m_downloadTimer; QElapsedTimer m_downloadTimer;
}; };
+353 -237
View File
@@ -1,150 +1,170 @@
#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"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "TicketHelper.h" #include "TicketHelper.h"
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include <QVersionNumber> #include <QVersionNumber>
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(65001);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater"); QApplication::setApplicationName("Marsco Updater");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
if (elevatedWriteExitCode >= 0)
return elevatedWriteExitCode;
UpdaterLogic logic; UpdaterLogic logic;
QString offlinePackagePath; QString offlinePackagePath;
QString appId; QString appId;
QString channel; QString channel;
QString targetVersion; QString targetVersion;
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();
appId = packageManifest.value("app_id").toString(); appId = packageManifest.value("app_id").toString();
channel = packageManifest.value("channel").toString(); channel = packageManifest.value("channel").toString();
targetVersion = packageManifest.value("version").toString(); targetVersion = packageManifest.value("version").toString();
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();
} }
QString bootstrapResult; QString bootstrapResult;
for (int i = 5; i < argc; ++i) { for (int i = 5; i < argc; ++i) {
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());
} }
const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath(); const QString runtimeDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
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")) { if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
return -1; return -1;
} }
QString fromVersion = config.getValue("App", "current_version"); QString fromVersion = config.getValue("App", "current_version");
if (!offlinePackagePath.isEmpty()) { if (!offlinePackagePath.isEmpty()) {
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;
} }
} }
QString deviceId = config.getValue("Update", "device_id"); QString deviceId = config.getValue("Update", "device_id");
if (deviceId.isEmpty()) deviceId = "unknown_device"; if (deviceId.isEmpty()) deviceId = "unknown_device";
if (!resumingFromBootstrap) { if (!resumingFromBootstrap) {
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);
progress.setAutoClose(false); progress.setAutoClose(false);
progress.setValue(2); progress.setValue(2);
progress.show(); progress.show();
QApplication::processEvents(); QApplication::processEvents();
UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId); UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId);
const auto setProgress = [&](int value, const QString& message) { const auto setProgress = [&](int value, const QString& message) {
progress.setValue(value); progress.setValue(value);
progress.setLabelText(message); progress.setLabelText(message);
QApplication::processEvents(); QApplication::processEvents();
}; };
const auto formatBytes = [](qint64 bytes) { const auto formatBytes = [](qint64 bytes) {
const double value = static_cast<double>(bytes); const double value = static_cast<double>(bytes);
if (bytes >= 1024LL * 1024 * 1024) if (bytes >= 1024LL * 1024 * 1024)
return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB"; return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB";
if (bytes >= 1024LL * 1024) if (bytes >= 1024LL * 1024)
return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB"; return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB";
if (bytes >= 1024) if (bytes >= 1024)
return QString::number(value / 1024.0, 'f', 1) + " KB"; return QString::number(value / 1024.0, 'f', 1) + " KB";
return QString::number(bytes) + " B"; return QString::number(bytes) + " B";
}; };
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...")
progress.setValue(value); : QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
progress.setLabelText(QString("%1\n%2 / %3 · %4/s") progress.setValue(value);
.arg(fileText, formatBytes(received), formatBytes(total), progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
formatBytes(static_cast<qint64>(bytesPerSecond)))); .arg(fileText, formatBytes(received), formatBytes(total),
QApplication::processEvents(); formatBytes(static_cast<qint64>(bytesPerSecond))));
}); QApplication::processEvents();
const auto reportUpdateResult = [&](bool success) { });
if (offlinePackagePath.isEmpty()) const auto reportUpdateResult = [&](bool success) {
logic.reportResult(deviceId, fromVersion, targetVersion, success); if (offlinePackagePath.isEmpty())
}; logic.reportResult(deviceId, fromVersion, targetVersion, success);
};
const auto fail = [&](const QString& title, const QString& message, const auto fail = [&](const QString& title, const QString& message,
const QString& errorCode = QString("update_failed")) { const QString& errorCode = QString("update_failed")) {
transaction.markFailed(errorCode, message); transaction.markFailed(errorCode, message);
@@ -153,258 +173,354 @@ 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);
bool timeoutOk = false; };
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); const QString mainExecutable = configuredName("main_executable", "MainApp");
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable); const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable);
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 = [&]() {
return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt"); return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt");
}; };
const auto launchBootstrap = [&](const QString& mode) { const auto launchBootstrap = [&](const QString& mode) {
const QStringList args{ const QStringList args{
bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath, bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath,
QString::number(QCoreApplication::applicationPid()), appId, channel, QString::number(QCoreApplication::applicationPid()), appId, channel,
targetVersion, QString::number(targetVersionId), mode targetVersion, QString::number(targetVersionId), mode
}; };
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args); return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
}; };
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();
return 0; return 0;
} }
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;
}; };
if (resumingFromBootstrap) { if (resumingFromBootstrap) {
QString resumeError; QString resumeError;
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();
if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode"))) if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode")))
offlinePackagePath = "__bootstrap_resumed_offline__"; offlinePackagePath = "__bootstrap_resumed_offline__";
if (bootstrapResult == "rolledback") { if (bootstrapResult == "rolledback") {
const bool stateOk = config.setValue("App", "current_version", fromVersion); const bool stateOk = config.setValue("App", "current_version", fromVersion);
transaction.markRolledBack(); transaction.markRolledBack();
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,
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache"); offlinePackagePath.isEmpty()
if (resumingFromBootstrap) { ? QCoreApplication::translate("Updater", "Fetching and verifying version manifest...")
: QCoreApplication::translate("Updater", "Verifying offline update package..."));
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。"); return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
} else if (offlinePackagePath.isEmpty()) { withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.errorString()));
} } else if (offlinePackagePath.isEmpty()) {
if (!logic.verifyManifestSignature()) { logic.getManifest(appId, channel, targetVersion, targetVersionId);
}
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()));
QStringList obsoletePaths; return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
if (!resumingFromBootstrap && fromVersion != targetVersion) { withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
UpdaterLogic oldManifestLogic; logic.errorString()),
if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion) "manifest_signature_invalid");
&& oldManifestLogic.getManifest().value("version").toString() == fromVersion }
&& oldManifestLogic.verifyManifestSignature()) { QStringList obsoletePaths;
obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest()); if (!resumingFromBootstrap && fromVersion != targetVersion) {
qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths; UpdaterLogic oldManifestLogic;
} else { if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion)
qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety"; && oldManifestLogic.getManifest().value("version").toString() == fromVersion
} && oldManifestLogic.verifyManifestSignature()) {
} obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest());
qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths;
} else {
qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety";
}
}
if (!logic.saveManifestCache(manifestCacheDir)) { if (!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."),
QStorageInfo storage(updateDir); logic.errorString()),
storage.refresh(); "empty_file_list");
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
QStorageInfo storage(updateDir);
storage.refresh();
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."),
QStringList changedPaths; logic.errorString()),
QDir stagingRoot(stagingDir); "staging_verify_failed");
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
while (stagingFiles.hasNext()) QStringList changedPaths;
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next()))); QDir stagingRoot(stagingDir);
const QString runtimePrefix = config.runtimeRelativePath(); QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
const QString bootstrapManifestPath = QDir::fromNativeSeparators( while (stagingFiles.hasNext())
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable); changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
const QString runtimePrefix = config.runtimeRelativePath();
const QString bootstrapManifestPath = QDir::fromNativeSeparators(
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
for (const QString& path : changedPaths) { 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"),
const auto writePlanItem = [&](char operation, const QString& path) { QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n'; "bootstrap_plan_failed");
return plan.write(line) == line.size(); const auto writePlanItem = [&](char operation, const QString& path) {
}; const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
for (const QString& path : changedPaths) { return plan.write(line) == line.size();
};
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"),
progress.close(); QCoreApplication::translate("Updater", "Cannot start the standalone update handoff program: %1").arg(bootstrapPath),
return 0; "bootstrap_start_failed");
} progress.close();
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()
QElapsedTimer healthTimer; ? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
healthTimer.start(); : mainStartupError);
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
QApplication::processEvents(); QElapsedTimer healthTimer;
QThread::msleep(100); healthTimer.start();
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
QApplication::processEvents();
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(bootstrapPlanFile()); QFile::remove(healthFile);
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,
return 0; 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;
}
+20 -21
View File
@@ -1,22 +1,21 @@
{ {
"app_id": "simcae", "app_id": "simcae",
"app_name": "SimCAE", "app_name": "SimCAE",
"channel": "stable", "channel": "stable",
"current_version": "1.0.0", "current_version": "1.0.0",
"client_protocol": "3", "client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "", "license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000", "client_token": "SimCAEClientToken2026",
"client_token": "SimCAEClientToken2026", "request_timeout_ms": "5000",
"request_timeout_ms": "5000", "temp_folder": "update_temp",
"temp_folder": "update_temp", "device_id": "",
"device_id": "", "install_root": "..",
"install_root": "..", "main_executable": "SimCAE.exe",
"main_executable": "SimCAE.exe", "launcher_executable": "Launcher.exe",
"launcher_executable": "Launcher.exe", "updater_executable": "Updater.exe",
"updater_executable": "Updater.exe", "bootstrap_executable": "Bootstrap.exe",
"bootstrap_executable": "Bootstrap.exe", "health_check_timeout_ms": "15000",
"health_check_timeout_ms": "15000", "platform": "windows",
"platform": "windows", "arch": "x64"
"arch": "x64" }
}
+21
View File
@@ -0,0 +1,21 @@
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE",
"launcher_executable": "Launcher",
"updater_executable": "Updater",
"bootstrap_executable": "Bootstrap",
"health_check_timeout_ms": "15000",
"platform": "linux",
"arch": "x64"
}
+7 -7
View File
@@ -1,9 +1,9 @@
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX1FSi06eP8XhX4B3Oy6
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l FzfkTe3FBIg6OjWpT1541LivRTRt9HXZ30nKuIs5itXBzBQPMU8hsfR0MD9nfPG/
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ NlajfZzqbyxAJlUMeThJiwtyz98wv3VE1hS2Bwuc3mbmtfU0zl/MoPdWrluL3x3C
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv bt0ylL00rLBuvjgG21zDflVqj3w9CwpKHFsNrSYoI6vOFX9YrRZ9tKcPEkSRPqts
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs 411QkAQLtMiOMIIhNe5aFIL7doCnglx32hJeHNCTUSxjM9VyHUTEj8wKN/6Gy5iP
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ YZGRafeiJZ32RRIHyssereAg3Xe/3scd+tbFNNYP9xrRhRgDacmkSCBodnbJd4Qc
TwIDAQAB rQIDAQAB
-----END PUBLIC KEY----- -----END PUBLIC KEY-----
+3
View File
@@ -0,0 +1,3 @@
{
"api_base_url": "http://192.168.229.128:8000"
}
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/simcae">
<file>server_config.json</file>
</qresource>
</RCC>
+176
View File
@@ -0,0 +1,176 @@
客户端国际化说明
================
这份文档说明 Qt 国际化文件怎么维护,以及哪些步骤是自动的、哪些步骤需要你手动做。
先看结论
========
Visual Studio 和 PowerShell 二选一即可。
- Visual Studio 是图形界面入口。
- PowerShell 是命令行入口。
- 两者最终调用的是同一套 CMake 目标,不是两套流程。
最重要的规则:
1. 普通“全部重新生成”会自动把已有的 update-client_zh_CN.ts 编译成 update-client_zh_CN.qm。
2. 普通“全部重新生成”不会自动扫描源码生成新的 update-client_zh_CN.ts 条目。
3. 如果新增了 QObject::tr(...)、QCoreApplication::translate(...) 这类新文案,必须先手动生成一次 update_client_lupdate。
4. 你编辑完 update-client_zh_CN.ts 后,再“全部重新生成”,CMake 会自动生成 .qm,并通过 qrc 打进程序。
文件说明
========
1. update-client_zh_CN.ts
翻译源文件,XML 格式。新增或修改代码里的 tr()/translate() 文案后,需要更新这个文件,再补中文翻译。
2. update-client_zh_CN.qm
Qt 运行时加载的二进制翻译文件。它由 .ts 编译生成,不要手工编辑。
3. update-client.qrc
Qt 资源文件。它会把 update-client_zh_CN.qm 编进 Launcher、Updater、Bootstrap、MainApp,不需要把 .qm 单独放到安装目录。
日常编译:没有新增界面文字
==========================
这种情况最简单。
你只是改了普通 C++ 代码,或者只是修改了 update-client_zh_CN.ts 里已有条目的中文翻译:
Visual Studio
```text
选择 x64 Release 或 x64 Debug -> 全部重新生成
```
PowerShell 等价命令:
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release
```
这时 CMake 会自动执行 lrelease
```text
update-client_zh_CN.ts -> update-client_zh_CN.qm
```
然后 .qm 会通过 update-client.qrc 打进 exe。
新增界面文字后的完整流程
========================
如果代码里新增了这些文字:
```cpp
QObject::tr("New message")
QCoreApplication::translate("Context", "New message")
```
只点“全部重新生成”是不够的。因为“全部重新生成”不会自动扫描源码,把新 source 写进 .ts。
正确流程是:
1. 先更新 .ts 文件。
Visual Studio
```text
在 CMake 目标里找到 update_client_lupdate,然后生成这个目标。
```
PowerShell 等价命令:
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release --target update_client_lupdate
```
这一步会扫描 Common、Bootstrap、Launcher、Updater、MainApp 里的 cpp/h 文件,把新增的 tr()/translate() 文案写入:
```text
i18n/update-client_zh_CN.ts
```
2. 编辑 update-client_zh_CN.ts。
找到新增的 `<source>...</source>`,把对应 `<translation>...</translation>` 补成中文。
可以用 Qt Linguist 打开,也可以直接用文本编辑器编辑 XML。
3. 再重新生成程序。
Visual Studio
```text
全部重新生成
```
PowerShell 等价命令:
```powershell
cmake --build --preset x64-release
```
这一步会自动做:
```text
update-client_zh_CN.ts -> update-client_zh_CN.qm -> update-client.qrc -> exe
```
如果只想单独生成 .qm
====================
一般不需要单独做。普通编译会自动生成 .qm。
如果你只是想检查 .ts 能不能正常编译成 .qm,可以单独生成这个目标:
Visual Studio
```text
生成 CMake 目标 update_client_translations
```
PowerShell
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release --target update_client_translations
```
常见问题
========
1. 新增了 tr(),为什么程序里没有中文?
通常是少做了 update_client_lupdate。新增文案后必须先更新 .ts,再补中文,再重新生成。
2. 我只改了 .ts 里的中文,还要跑 update_client_lupdate 吗?
不需要。直接“全部重新生成”即可,CMake 会自动重新生成 .qm。
3. update-client_zh_CN.qm 要不要交付到安装目录?
不需要。它已经通过 update-client.qrc 编进 exe。
4. 代码里能不能直接写中文?
不建议。界面文字统一写英文 source,然后在 .ts 里翻译成中文。
5. Visual Studio 或 CMake 找不到 lupdate / lrelease 怎么办?
通常是 Qt 环境变量没配好。确认 CMAKE_PREFIX_PATH 或 Qt5_DIR 指向 Qt 目录。
Windows 示例:
```powershell
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
```
Linux 示例:
```bash
sudo apt install -y qttools5-dev-tools
```
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/i18n">
<file>update-client_zh_CN.qm</file>
</qresource>
</RCC>
Binary file not shown.
File diff suppressed because it is too large Load Diff
-2943
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
客户端脚本说明
==============
本目录保存 update-client 的辅助脚本。项目根目录只保留源码、CMake 入口、Docs 和配置模板,脚本统一放在这里。
脚本列表:
1. package-sdk.ps1
在 Windows 上生成给其他软件接入用的 UpdateClientSDK 包。
注意:SDK 包只面向运行接入,不包含 config/server_config.json 和 config/server_config.qrc。
服务端地址必须在打包前写入源码目录 config/server_config.json,并重新编译进 Launcher/Updater。
2. package-client.ps1
在 Windows 上生成某个具体产品的最终客户端发布包。
3. install-sdk.ps1
将 SDK 运行时复制到业务软件 Release 目录。
4. package-sdk.sh
在 Linux 上生成给其他软件接入用的 UpdateClientSDK 包,输出 tar.gz。
5. package-client.sh
在 Linux 上生成某个具体产品的最终客户端发布包,输出 tar.gz。
推荐在 update-client 根目录执行:
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
```powershell
.\scripts\package-client.ps1 `
-SourceDir .\out\bin\Release `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
Linux 示例:
```bash
bash ./scripts/package-sdk.sh \
--source-dir ./out/linux/bin \
--output-dir ./dist/UpdateClientSDK-linux \
--archive ./dist/UpdateClientSDK-linux.tar.gz \
--sdk-version 0.1.0
```
```bash
bash ./scripts/package-client.sh \
--source-dir /path/to/SimCAE \
--config-file /path/to/SimCAE/bin/config/app_config.json \
--output-dir ./dist/UpdateClient-linux \
--archive ./dist/UpdateClient-linux.tar.gz
```
+74 -74
View File
@@ -1,74 +1,74 @@
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$SdkRoot, [string]$SdkRoot,
[string]$ReleaseDir = (Get-Location).Path, [string]$ReleaseDir = (Get-Location).Path,
[switch]$OverwriteConfig, [switch]$OverwriteConfig,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$sdk = (Resolve-Path $SdkRoot).Path $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" $configDir = Join-Path $sdk "config"
$appConfig = Join-Path $configDir "app_config.json" $appConfig = Join-Path $configDir "app_config.json"
$publicKey = Join-Path $configDir "manifest_public_key.pem" $publicKey = Join-Path $configDir "manifest_public_key.pem"
foreach ($path in @($binDir, $appConfig, $publicKey)) { foreach ($path in @($binDir, $appConfig, $publicKey)) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
throw "SDK file is missing: $path" throw "SDK file is missing: $path"
} }
} }
function Test-IsQtRuntimeItem { function Test-IsQtRuntimeItem {
param([System.IO.FileSystemInfo]$Item) param([System.IO.FileSystemInfo]$Item)
if ($IncludeQtRuntime) { if ($IncludeQtRuntime) {
return $false return $false
} }
if ($Item.PSIsContainer) { if ($Item.PSIsContainer) {
return $Item.Name -in @( return $Item.Name -in @(
"bearer", "bearer",
"iconengines", "iconengines",
"imageformats", "imageformats",
"platforms", "platforms",
"styles", "styles",
"translations" "translations"
) )
} }
return ( return (
$Item.Name -match '^Qt5.*\.dll$' -or $Item.Name -match '^Qt5.*\.dll$' -or
$Item.Name -in @( $Item.Name -in @(
"libEGL.dll", "libEGL.dll",
"libGLESv2.dll", "libGLESv2.dll",
"opengl32sw.dll", "opengl32sw.dll",
"d3dcompiler_47.dll" "d3dcompiler_47.dll"
) )
) )
} }
Get-ChildItem $binDir -Force | Where-Object { Get-ChildItem $binDir -Force | Where-Object {
-not (Test-IsQtRuntimeItem $_) -not (Test-IsQtRuntimeItem $_)
} | Copy-Item -Destination $release -Recurse -Force } | Copy-Item -Destination $release -Recurse -Force
$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" $targetAppConfig = Join-Path $targetConfigDir "app_config.json"
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) { if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
Copy-Item $appConfig $targetAppConfig -Force Copy-Item $appConfig $targetAppConfig -Force
} else { } else {
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it." Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
} }
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force 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: edit config/app_config.json, then start Launcher.exe."
+146 -111
View File
@@ -1,34 +1,45 @@
param( param(
[string]$SourceDir = "$PSScriptRoot/out/bin", [string]$SourceDir = "",
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ConfigFile, [string]$ConfigFile,
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClient", [string]$OutputDir = "",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip" [string]$ZipFile = ""
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
}
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClient"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClient.zip"
}
$source = (Resolve-Path $SourceDir).Path $source = (Resolve-Path $SourceDir).Path
$config = (Resolve-Path $ConfigFile).Path $config = (Resolve-Path $ConfigFile).Path
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json $settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
function Normalize-RelativePath([string]$PathValue) { function Normalize-RelativePath([string]$PathValue) {
return ($PathValue -replace '\\', '/').Trim('/') return ($PathValue -replace '\\', '/').Trim('/')
} }
function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) { function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) {
$full = (Resolve-Path $FullPath).Path $full = (Resolve-Path $FullPath).Path
if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) { if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) {
return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/')) return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/'))
} }
return Normalize-RelativePath $Fallback return Normalize-RelativePath $Fallback
} }
$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json" $configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json"
$configRelativeParent = Split-Path $configRelativePath -Parent $configRelativeParent = Split-Path $configRelativePath -Parent
$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent) $runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent)
if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" } if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" }
function Join-RelativePath([string]$Base, [string]$Child) { function Join-RelativePath([string]$Base, [string]$Child) {
$baseNorm = Normalize-RelativePath $Base $baseNorm = Normalize-RelativePath $Base
$childNorm = Normalize-RelativePath $Child $childNorm = Normalize-RelativePath $Child
@@ -37,102 +48,126 @@ function Join-RelativePath([string]$Base, [string]$Child) {
return "$baseNorm/$childNorm" return "$baseNorm/$childNorm"
} }
function Get-InstallDirectoryId([string]$RuntimeDir) {
$normalized = ([IO.Path]::GetFullPath($RuntimeDir) -replace '\\', '/').TrimEnd('/')
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
$hash = $sha.ComputeHash($bytes)
return -join ($hash | ForEach-Object { $_.ToString("x2") })
} finally {
$sha.Dispose()
}
}
function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestName) {
$localData = [Environment]::GetFolderPath("LocalApplicationData")
if ([string]::IsNullOrWhiteSpace($localData)) { return "" }
$installId = Get-InstallDirectoryId $RuntimeDir
return Join-Path $localData "Marsco\UpdateClientSDK\installations\$installId\update\manifest_cache\$ManifestName"
}
$requiredFields = @( $requiredFields = @(
"app_id", "channel", "api_base_url", "current_version", "app_id", "channel", "current_version",
"client_token", "launch_token", "license_key", "client_token", "launch_token", "license_key",
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
) )
foreach ($field in $requiredFields) { foreach ($field in $requiredFields) {
if (-not $settings.$field) { if (-not $settings.$field) {
throw "Config file is missing required field: $field" throw "Config file is missing required field: $field"
} }
} }
$mainExecutable = Normalize-RelativePath $settings.main_executable $mainExecutable = Normalize-RelativePath $settings.main_executable
$launcherExecutable = Normalize-RelativePath $settings.launcher_executable $launcherExecutable = Normalize-RelativePath $settings.launcher_executable
$updaterExecutable = Normalize-RelativePath $settings.updater_executable $updaterExecutable = Normalize-RelativePath $settings.updater_executable
$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable $bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable
$requiredFiles = @( $requiredFiles = @(
(Join-RelativePath $runtimeDirRelative $mainExecutable), (Join-RelativePath $runtimeDirRelative $mainExecutable),
(Join-RelativePath $runtimeDirRelative $launcherExecutable), (Join-RelativePath $runtimeDirRelative $launcherExecutable),
(Join-RelativePath $runtimeDirRelative $updaterExecutable), (Join-RelativePath $runtimeDirRelative $updaterExecutable),
(Join-RelativePath $runtimeDirRelative $bootstrapExecutable) (Join-RelativePath $runtimeDirRelative $bootstrapExecutable)
) )
foreach ($name in $requiredFiles) { foreach ($name in $requiredFiles) {
$nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar $nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar
if (-not (Test-Path (Join-Path $source $nativeName))) { if (-not (Test-Path (Join-Path $source $nativeName))) {
throw "Source directory is missing required file: $name" throw "Source directory is missing required file: $name"
} }
} }
$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')
} }
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
$mainLeafName = Split-Path $mainExecutable -Leaf $mainLeafName = Split-Path $mainExecutable -Leaf
$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object { $duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object {
$relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/')) $relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/'))
$relative -ne $expectedMainPath $relative -ne $expectedMainPath
} | Select-Object -First 1 } | Select-Object -First 1
if ($duplicateMain) { if ($duplicateMain) {
throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)" throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)"
} }
$manifestName = "manifest_$($settings.current_version).json" $manifestName = "manifest_$($settings.current_version).json"
$runtimeDirAbsolute = if ([string]::IsNullOrWhiteSpace($runtimeDirRelative)) {
$source
} else {
Join-Path $source ($runtimeDirRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
}
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName" $sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar) $manifestCandidates = @(
if (-not (Test-Path $sourceManifest)) { (Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName" (Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
if (Test-Path $legacySourceManifest) { (Join-Path $source "update/manifest_cache/$manifestName")
$sourceManifest = $legacySourceManifest ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
} $sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $sourceManifest -or -not (Test-Path $sourceManifest)) {
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
throw "Missing signed Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging. Searched paths:$([Environment]::NewLine)$searched"
} }
if (-not (Test-Path $sourceManifest)) {
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." if (Test-Path $OutputDir) {
} Remove-Item $OutputDir -Recurse -Force
}
if (Test-Path $OutputDir) { New-Item $OutputDir -ItemType Directory -Force | Out-Null
Remove-Item $OutputDir -Recurse -Force
} Get-ChildItem $source -Force | Where-Object {
New-Item $OutputDir -ItemType Directory -Force | Out-Null $_.Name -notin @("update", "update_temp")
} | Copy-Item -Destination $OutputDir -Recurse -Force
Get-ChildItem $source -Force | Where-Object {
$_.Name -notin @("update", "update_temp") $outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar)
} | Copy-Item -Destination $OutputDir -Recurse -Force if (Test-Path $outputRuntimeUpdateDir) {
Remove-Item $outputRuntimeUpdateDir -Recurse -Force
$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar) }
if (Test-Path $outputRuntimeUpdateDir) { $outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar)
Remove-Item $outputRuntimeUpdateDir -Recurse -Force if (Test-Path $outputRuntimeTempDir) {
} Remove-Item $outputRuntimeTempDir -Recurse -Force
$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar) }
if (Test-Path $outputRuntimeTempDir) {
Remove-Item $outputRuntimeTempDir -Recurse -Force $outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar)
} $outputConfigDir = Split-Path $outputConfigPath -Parent
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) Copy-Item $config $outputConfigPath -Force
$outputConfigDir = Split-Path $outputConfigPath -Parent
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null @("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
Copy-Item $config $outputConfigPath -Force $runtimeFile = Join-Path $outputConfigDir $_
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force }
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { }
$runtimeFile = Join-Path $outputConfigDir $_
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } $manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
} New-Item $manifestDir -ItemType Directory -Force | Out-Null
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
New-Item $manifestDir -ItemType Directory -Force | Out-Null $zipParent = Split-Path $ZipFile -Parent
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force New-Item $zipParent -ItemType Directory -Force | Out-Null
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
$zipParent = Split-Path $ZipFile -Parent Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
New-Item $zipParent -ItemType Directory -Force | Out-Null
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } Write-Host "Package directory: $OutputDir"
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal Write-Host "ZIP file: $ZipFile"
Write-Host "Package directory: $OutputDir"
Write-Host "ZIP file: $ZipFile"
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
CONFIG_FILE=""
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClient-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClient-linux.tar.gz"
SKIP_MANIFEST_CHECK=0
usage() {
cat <<'EOF'
Usage: package-client.sh --config-file FILE [options]
Options:
--source-dir DIR Release/install root to package. Default: ./out/linux/bin
--config-file FILE app_config.json used by this client package. Required.
--output-dir DIR Output directory. Default: ./dist/UpdateClient-linux
--archive FILE Output tar.gz. Default: ./dist/UpdateClient-linux.tar.gz
--skip-manifest-check Skip current-version manifest cache check.
-h, --help Show this help.
EOF
}
normalize_relative_path() {
local value="${1:-}"
value="${value//\\//}"
value="${value#/}"
value="${value%/}"
printf '%s' "$value"
}
join_relative_path() {
local base
local child
base="$(normalize_relative_path "${1:-}")"
child="$(normalize_relative_path "${2:-}")"
if [[ -z "$base" ]]; then printf '%s' "$child"; return; fi
if [[ -z "$child" ]]; then printf '%s' "$base"; return; fi
printf '%s/%s' "$base" "$child"
}
json_value() {
python3 - "$CONFIG_FILE" "$1" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8-sig") as f:
data = json.load(f)
value = data.get(sys.argv[2], "")
print("" if value is None else value)
PY
}
relative_to_source() {
local full="$1"
local fallback="$2"
python3 - "$SOURCE_DIR" "$full" "$fallback" <<'PY'
import os
import sys
source = os.path.realpath(sys.argv[1])
full = os.path.realpath(sys.argv[2])
fallback = sys.argv[3]
try:
rel = os.path.relpath(full, source)
except ValueError:
rel = fallback
if rel.startswith(".."):
rel = fallback
print(rel.replace(os.sep, "/").strip("/"))
PY
}
install_directory_id() {
python3 - "$1" <<'PY'
import hashlib
import os
import sys
value = os.path.realpath(sys.argv[1]).replace(os.sep, "/").rstrip("/")
print(hashlib.sha256(value.encode("utf-8")).hexdigest())
PY
}
user_data_manifest_candidate() {
local runtime_dir="$1"
local manifest_name="$2"
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local install_id
install_id="$(install_directory_id "$runtime_dir")"
printf '%s/Marsco/UpdateClientSDK/installations/%s/update/manifest_cache/%s' \
"$data_home" "$install_id" "$manifest_name"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--config-file) CONFIG_FILE="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--skip-manifest-check) SKIP_MANIFEST_CHECK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$CONFIG_FILE" ]]; then
echo "--config-file is required." >&2
usage >&2
exit 2
fi
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
CONFIG_FILE="$(realpath "$CONFIG_FILE")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
for field in app_id channel current_version client_token launch_token license_key main_executable launcher_executable updater_executable bootstrap_executable; do
if [[ -z "$(json_value "$field")" ]]; then
echo "Config file is missing required field: $field" >&2
exit 1
fi
done
CONFIG_RELATIVE_PATH="$(relative_to_source "$CONFIG_FILE" "config/app_config.json")"
CONFIG_RELATIVE_PARENT="$(dirname "$CONFIG_RELATIVE_PATH")"
[[ "$CONFIG_RELATIVE_PARENT" == "." ]] && CONFIG_RELATIVE_PARENT=""
RUNTIME_DIR_RELATIVE="$(normalize_relative_path "$(dirname "$CONFIG_RELATIVE_PARENT")")"
[[ "$RUNTIME_DIR_RELATIVE" == "." ]] && RUNTIME_DIR_RELATIVE=""
MAIN_EXECUTABLE="$(normalize_relative_path "$(json_value main_executable)")"
LAUNCHER_EXECUTABLE="$(normalize_relative_path "$(json_value launcher_executable)")"
UPDATER_EXECUTABLE="$(normalize_relative_path "$(json_value updater_executable)")"
BOOTSTRAP_EXECUTABLE="$(normalize_relative_path "$(json_value bootstrap_executable)")"
CURRENT_VERSION="$(json_value current_version)"
for required in \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")"; do
if [[ ! -f "$SOURCE_DIR/$required" ]]; then
echo "Source directory is missing required file: $required" >&2
exit 1
fi
done
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "Source directory contains Debug artifacts. Use a clean Release root directory." >&2
echo "Example: $DEBUG_ARTIFACT" >&2
exit 1
fi
EXPECTED_MAIN_PATH="$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")"
MAIN_LEAF="$(basename "$MAIN_EXECUTABLE")"
DUPLICATE_MAIN="$({ find "$SOURCE_DIR" -type f -name "$MAIN_LEAF" | while read -r item; do
rel="$(python3 - "$SOURCE_DIR" "$item" <<'PY'
import os, sys
print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])).replace(os.sep, "/"))
PY
)"
[[ "$rel" != "$EXPECTED_MAIN_PATH" ]] && { echo "$item"; break; }
done; } || true)"
if [[ -n "$DUPLICATE_MAIN" ]]; then
echo "Source directory contains a duplicate main executable outside $EXPECTED_MAIN_PATH: $DUPLICATE_MAIN" >&2
exit 1
fi
MANIFEST_NAME="manifest_${CURRENT_VERSION}.json"
if [[ -z "$RUNTIME_DIR_RELATIVE" ]]; then
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR"
else
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR/$RUNTIME_DIR_RELATIVE"
fi
MANIFEST_CANDIDATES=(
"$(user_data_manifest_candidate "$RUNTIME_DIR_ABSOLUTE" "$MANIFEST_NAME")"
"$SOURCE_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache/$MANIFEST_NAME")"
"$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME"
)
SOURCE_MANIFEST=""
for candidate in "${MANIFEST_CANDIDATES[@]}"; do
if [[ -f "$candidate" ]]; then
SOURCE_MANIFEST="$candidate"
break
fi
done
if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then
echo "Missing signed Manifest cache for current version: $MANIFEST_NAME." >&2
echo "Complete online update/verification for this version before packaging. Searched paths:" >&2
printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do
base="$(basename "$item")"
case "$base" in
update|update_temp) ;;
*) cp -a "$item" "$OUTPUT_DIR/" ;;
esac
done
shopt -u dotglob nullglob
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update")"
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update_temp")"
OUTPUT_CONFIG_PATH="$OUTPUT_DIR/$CONFIG_RELATIVE_PATH"
mkdir -p "$(dirname "$OUTPUT_CONFIG_PATH")"
cp "$CONFIG_FILE" "$OUTPUT_CONFIG_PATH"
rm -f "$(dirname "$OUTPUT_CONFIG_PATH")/client_identity.dat" \
"$(dirname "$OUTPUT_CONFIG_PATH")/local_state.json" \
"$(dirname "$OUTPUT_CONFIG_PATH")/version_policy.dat"
if [[ -f "$SOURCE_MANIFEST" ]]; then
MANIFEST_DIR="$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache")"
mkdir -p "$MANIFEST_DIR"
cp "$SOURCE_MANIFEST" "$MANIFEST_DIR/$MANIFEST_NAME"
fi
chmod +x "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" 2>/dev/null || true
mkdir -p "$(dirname "$ARCHIVE_FILE")"
rm -f "$ARCHIVE_FILE"
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
echo "Package directory: $OUTPUT_DIR"
echo "Archive file: $ARCHIVE_FILE"
+133 -90
View File
@@ -1,116 +1,159 @@
param( param(
[string]$SourceDir = "$PSScriptRoot/out/bin", [string]$SourceDir = "",
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK", [string]$OutputDir = "",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", [string]$ZipFile = "",
[string]$SdkVersion = "0.1.0", [string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", [string]$ExampleConfig = "",
[switch]$IncludeDemoMainApp, [switch]$IncludeDemoMainApp,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$source = (Resolve-Path $SourceDir).Path $RepoRoot = Split-Path -Parent $PSScriptRoot
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") }
foreach ($name in $requiredFiles) { if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$path = Join-Path $source $name $OutputDir = Join-Path $RepoRoot "dist/UpdateClientSDK"
if (-not (Test-Path $path)) { }
throw "SDK source directory is missing required file: $path" if ([string]::IsNullOrWhiteSpace($ZipFile)) {
} $ZipFile = Join-Path $RepoRoot "dist/UpdateClientSDK.zip"
}
if ([string]::IsNullOrWhiteSpace($ExampleConfig)) {
$ExampleConfig = Join-Path $RepoRoot "config/app_config.example.json"
} }
$source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
foreach ($name in $requiredFiles) {
$path = Join-Path $source $name
if (-not (Test-Path $path)) {
throw "SDK source directory is missing required file: $path"
}
}
$publicKeyCandidates = @( $publicKeyCandidates = @(
(Join-Path $source "config/manifest_public_key.pem"), (Join-Path $source "config/manifest_public_key.pem"),
(Join-Path $source "manifest_public_key.pem"), (Join-Path $source "manifest_public_key.pem"),
(Join-Path $PSScriptRoot "config/manifest_public_key.pem") (Join-Path $RepoRoot "config/manifest_public_key.pem")
) )
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 $publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) { if (-not $publicKey) {
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." 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')
} }
if ($debugArtifacts) { if ($debugArtifacts) {
throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)" throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)"
} }
if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force } if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force }
New-Item $OutputDir -ItemType Directory -Force | Out-Null 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"
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem") New-Item $binDir,$configDir,$scriptsDir,$commonDir,$docsDir -ItemType Directory -Force | Out-Null
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
if (-not $IncludeQtRuntime) { if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
$excludedTopLevel += @(
"bearer", if (-not $IncludeQtRuntime) {
"iconengines", $excludedTopLevel += @(
"imageformats", "bearer",
"platforms", "iconengines",
"styles", "imageformats",
"translations" "platforms",
) "styles",
} "translations"
)
function Test-IsQtRuntimeFile { }
param([System.IO.FileSystemInfo]$Item)
function Test-IsQtRuntimeFile {
if ($IncludeQtRuntime -or $Item.PSIsContainer) { param([System.IO.FileSystemInfo]$Item)
return $false
} if ($IncludeQtRuntime -or $Item.PSIsContainer) {
return $false
return ( }
$Item.Name -match '^Qt5.*\.dll$' -or
$Item.Name -in @( return (
"libEGL.dll", $Item.Name -match '^Qt5.*\.dll$' -or
"libGLESv2.dll", $Item.Name -in @(
"opengl32sw.dll", "libEGL.dll",
"d3dcompiler_47.dll" "libGLESv2.dll",
) "opengl32sw.dll",
) "d3dcompiler_47.dll"
} )
)
Get-ChildItem $source -Force | Where-Object { }
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
} | Copy-Item -Destination $binDir -Recurse -Force Get-ChildItem $source -Force | Where-Object {
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
} | Copy-Item -Destination $binDir -Recurse -Force
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { $commonSourceDir = Join-Path $RepoRoot "Common"
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" $commonSourceFiles = @(
} | Sort-Object Name | Select-Object -First 1 "ConfigHelper.h",
if (-not $wordGuideSource) { "ConfigHelper.cpp",
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." "TicketHelper.h",
"TicketHelper.cpp"
)
foreach ($commonFile in $commonSourceFiles) {
$commonPath = Join-Path $commonSourceDir $commonFile
if (-not (Test-Path $commonPath)) {
throw "SDK Common integration source is missing: $commonPath"
}
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
}
$docsSourceDir = Join-Path $RepoRoot "Docs"
if (Test-Path $docsSourceDir) {
Copy-Item (Join-Path $docsSourceDir "*") $docsDir -Recurse -Force
} }
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 $wordGuideSource = @($RepoRoot, $docsSourceDir) |
Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force Where-Object { Test-Path $_ } |
Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force ForEach-Object { Get-ChildItem $_ -File -Filter "*.docx" } |
Where-Object { $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" } |
@{ Sort-Object Name |
sdk_version = $SdkVersion Select-Object -First 1
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") if ($wordGuideSource) {
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
} else {
Write-Warning "SDK Word guide is missing. Continue packaging with Markdown documents in Docs/."
}
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force
Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force
@{
sdk_version = $SdkVersion
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
sdk_type = "external-updater-runtime" sdk_type = "external-updater-runtime"
required_entry = "Launcher.exe" required_entry = "Launcher.exe"
contains_demo_main_app = [bool]$IncludeDemoMainApp contains_demo_main_app = [bool]$IncludeDemoMainApp
docs_entry = "Docs/01-客户端接入打包部署指南.md"
word_guide_included = [bool]$wordGuideSource
integration_sources = $commonSourceFiles
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8 } | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
$zipParent = Split-Path $ZipFile -Parent $zipParent = Split-Path $ZipFile -Parent
New-Item $zipParent -ItemType Directory -Force | Out-Null New-Item $zipParent -ItemType Directory -Force | Out-Null
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
Write-Host "SDK directory: $OutputDir" Write-Host "SDK directory: $OutputDir"
Write-Host "SDK zip: $ZipFile" Write-Host "SDK zip: $ZipFile"
Write-Host "SDK version: $SdkVersion" Write-Host "SDK version: $SdkVersion"
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz"
SDK_VERSION="0.1.0"
EXAMPLE_CONFIG="$REPO_ROOT/config/app_config.linux.example.json"
INCLUDE_DEMO_MAIN_APP=0
usage() {
cat <<'EOF'
Usage: package-sdk.sh [options]
Options:
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
--output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux
--archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz
--sdk-version VERSION SDK version. Default: 0.1.0
--example-config FILE app_config template. Default: ./config/app_config.linux.example.json
--include-demo-mainapp Include MainApp demo executable in SDK bin.
-h, --help Show this help.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--sdk-version) SDK_VERSION="$2"; shift 2 ;;
--example-config) EXAMPLE_CONFIG="$2"; shift 2 ;;
--include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
EXAMPLE_CONFIG="$(realpath "$EXAMPLE_CONFIG")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
for name in Launcher Updater Bootstrap; do
if [[ ! -f "$SOURCE_DIR/$name" ]]; then
echo "SDK source directory is missing required file: $SOURCE_DIR/$name" >&2
exit 1
fi
done
PUBLIC_KEY=""
for candidate in \
"$SOURCE_DIR/config/manifest_public_key.pem" \
"$SOURCE_DIR/manifest_public_key.pem" \
"$REPO_ROOT/config/manifest_public_key.pem"; do
if [[ -f "$candidate" ]]; then
PUBLIC_KEY="$candidate"
break
fi
done
if [[ -z "$PUBLIC_KEY" ]]; then
echo "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." >&2
exit 1
fi
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2
echo "Example: $DEBUG_ARTIFACT" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common" "$OUTPUT_DIR/Docs"
shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do
base="$(basename "$item")"
case "$base" in
config|update|update_temp|manifest_public_key.pem|MainApp)
if [[ "$base" == "MainApp" && "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]]; then
cp -a "$item" "$OUTPUT_DIR/bin/"
fi
;;
*)
cp -a "$item" "$OUTPUT_DIR/bin/"
;;
esac
done
shopt -u dotglob nullglob
cp "$EXAMPLE_CONFIG" "$OUTPUT_DIR/config/app_config.json"
cp "$PUBLIC_KEY" "$OUTPUT_DIR/config/manifest_public_key.pem"
for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.cpp; do
common_path="$REPO_ROOT/Common/$common_file"
if [[ ! -f "$common_path" ]]; then
echo "SDK Common integration source is missing: $common_path" >&2
exit 1
fi
cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
done
if [[ -d "$REPO_ROOT/Docs" ]]; then
cp -a "$REPO_ROOT/Docs/." "$OUTPUT_DIR/Docs/"
fi
WORD_GUIDE="$(find "$REPO_ROOT" "$REPO_ROOT/Docs" -maxdepth 1 -type f -name '*SDK*.docx' ! -name '~$*' 2>/dev/null | sort | sed -n '1p')"
WORD_GUIDE_INCLUDED=false
if [[ -n "$WORD_GUIDE" ]]; then
cp "$WORD_GUIDE" "$OUTPUT_DIR/$(basename "$WORD_GUIDE")"
WORD_GUIDE_INCLUDED=true
else
echo "Warning: SDK Word guide is missing. Continue packaging with Markdown documents in Docs/." >&2
fi
cp "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh"
cp "$SCRIPT_DIR/package-client.sh" "$OUTPUT_DIR/scripts/package-client.sh"
if [[ -f "$SCRIPT_DIR/install-sdk.ps1" ]]; then cp "$SCRIPT_DIR/install-sdk.ps1" "$OUTPUT_DIR/scripts/install-sdk.ps1"; fi
if [[ -f "$SCRIPT_DIR/package-sdk.ps1" ]]; then cp "$SCRIPT_DIR/package-sdk.ps1" "$OUTPUT_DIR/scripts/package-sdk.ps1"; fi
if [[ -f "$SCRIPT_DIR/package-client.ps1" ]]; then cp "$SCRIPT_DIR/package-client.ps1" "$OUTPUT_DIR/scripts/package-client.ps1"; fi
chmod +x "$OUTPUT_DIR/bin/Launcher" "$OUTPUT_DIR/bin/Updater" "$OUTPUT_DIR/bin/Bootstrap" 2>/dev/null || true
chmod +x "$OUTPUT_DIR/scripts/package-sdk.sh" "$OUTPUT_DIR/scripts/package-client.sh"
cat > "$OUTPUT_DIR/sdk_manifest.json" <<EOF
{
"sdk_version": "$SDK_VERSION",
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"sdk_type": "external-updater-runtime",
"platform": "linux",
"required_entry": "Launcher",
"contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false),
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
"word_guide_included": $WORD_GUIDE_INCLUDED,
"integration_sources": [
"ConfigHelper.h",
"ConfigHelper.cpp",
"TicketHelper.h",
"TicketHelper.cpp"
]
}
EOF
mkdir -p "$(dirname "$ARCHIVE_FILE")"
rm -f "$ARCHIVE_FILE"
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
echo "SDK directory: $OUTPUT_DIR"
echo "SDK archive: $ARCHIVE_FILE"
echo "SDK version: $SDK_VERSION"