Compare commits

..

14 Commits

50 changed files with 5490 additions and 2800 deletions
+1
View File
@@ -29,6 +29,7 @@ pdf_requirements.txt
*.obj
*.o
*.pdb
*.qm
*.ilk
*.idb
*.tlog
+17 -3
View File
@@ -1,6 +1,20 @@
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_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
target_link_libraries(Bootstrap PRIVATE shell32)
target_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets)
if(WIN32)
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
add_custom_command(TARGET Bootstrap POST_BUILD
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Bootstrap>
COMMENT "Deploy Qt runtime for Bootstrap"
)
endif()
+151 -138
View File
@@ -1,190 +1,203 @@
#include <windows.h>
#include <shellapi.h>
#include <filesystem>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
#include <QApplication>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMessageBox>
#include <QProcess>
#include <QTextStream>
#include <QThread>
#include <QTranslator>
#include <QVector>
namespace fs = std::filesystem;
static std::wstring quote(const std::wstring& value)
struct PlanItem
{
std::wstring result = L"\"";
unsigned backslashes = 0;
for (wchar_t ch : value) {
if (ch == L'\\') { ++backslashes; continue; }
if (ch == L'\"') {
result.append(backslashes * 2 + 1, L'\\');
result.push_back(L'\"');
backslashes = 0;
continue;
}
result.append(backslashes, L'\\');
backslashes = 0;
result.push_back(ch);
}
result.append(backslashes * 2, L'\\');
result.push_back(L'\"');
return result;
QChar operation;
QString relativePath;
};
static void showError(const QString& message)
{
QMessageBox::critical(nullptr,
QApplication::translate("Bootstrap", "Update Handoff Failed"),
message);
}
static std::wstring fromUtf8(const std::string& value)
static QString cleanRelativePath(const QString& value)
{
if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0);
if (size <= 0) return {};
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), result.data(), size);
return result;
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 {};
}
return path;
}
static bool safeRelative(const fs::path& path)
static QString joinPath(const QString& root, const QString& relativePath)
{
if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
for (const auto& part : path)
if (part == L"..") return false;
return QDir(root).filePath(relativePath);
}
static bool removeWithRetry(const QString& path)
{
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
for (int i = 0; i < 100; ++i) {
if (!QFileInfo::exists(path))
return true;
if (QFile::remove(path))
return true;
QThread::msleep(100);
}
return !QFileInfo::exists(path);
}
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
static bool copyWithRetry(const QString& source, const QString& destination)
{
std::error_code ec;
fs::create_directories(destination.parent_path(), ec);
QDir().mkpath(QFileInfo(destination).absolutePath());
for (int i = 0; i < 100; ++i) {
ec.clear();
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec);
if (!ec) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
QFile::remove(destination);
if (QFile::copy(source, destination))
return true;
QThread::msleep(100);
}
return false;
}
static bool removeWithRetry(const fs::path& path)
{
std::error_code ec;
for (int i = 0; i < 100; ++i) {
ec.clear();
if (!fs::exists(path, ec)) return !ec;
if (fs::remove(path, ec)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
static bool rollback(const fs::path& installDir, const fs::path& backupDir,
const std::vector<fs::path>& paths)
static bool rollback(const QString& installDir, const QString& backupDir,
const QVector<QString>& paths)
{
bool success = true;
for (const auto& relative : paths) {
const fs::path destination = installDir / relative;
const fs::path backup = backupDir / relative;
std::error_code ec;
if (fs::exists(backup, ec)) {
if (!copyWithRetry(backup, destination)) success = false;
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
for (const QString& relativePath : paths) {
const QString destination = joinPath(installDir, relativePath);
const QString backup = joinPath(backupDir, relativePath);
if (QFileInfo::exists(backup)) {
if (!copyWithRetry(backup, destination))
success = false;
} else if (QFileInfo::exists(destination) && !removeWithRetry(destination)) {
success = false;
}
}
return success;
}
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
const std::wstring& result)
static bool launchUpdater(const QString& updater, const QStringList& updateArgs,
const QString& result)
{
std::wstring command = quote(updater);
for (const auto& arg : updateArgs) command += L" " + quote(arg);
command += L" " + quote(L"--bootstrap-resume=" + result);
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
std::vector<wchar_t> mutableCommand(command.begin(), command.end());
mutableCommand.push_back(L'\0');
const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr,
FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi);
if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); }
return ok == TRUE;
QStringList args = updateArgs;
args.append(QStringLiteral("--bootstrap-resume=%1").arg(result));
return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath());
}
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths)
{
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!argv || argc < 11) {
MessageBoxW(nullptr, L"Bootstrap arguments are incomplete.", L"Update Handoff Failed", MB_ICONERROR);
if (argv) LocalFree(argv);
QFile file(planFile);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return false;
QTextStream input(&file);
input.setCodec("UTF-8");
while (!input.atEnd()) {
const QString line = input.readLine();
if (line.size() < 3 || line.at(1) != QLatin1Char('\t')
|| (line.at(0) != QLatin1Char('C') && line.at(0) != QLatin1Char('D'))) {
return false;
}
const QString relativePath = cleanRelativePath(line.mid(2));
if (relativePath.isEmpty())
return false;
items->append({line.at(0), relativePath});
paths->append(relativePath);
}
return true;
}
static bool isBootstrapSelfPath(const QString& relativePath)
{
const QString fileName = QFileInfo(relativePath).fileName();
return fileName.compare(QStringLiteral("Bootstrap.exe"), Qt::CaseInsensitive) == 0
|| fileName.compare(QStringLiteral("Bootstrap"), Qt::CaseInsensitive) == 0;
}
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
QTranslator translator;
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
app.installTranslator(&translator);
const QStringList arguments = QCoreApplication::arguments();
if (arguments.size() < 11) {
showError(QApplication::translate("Bootstrap", "Bootstrap arguments are incomplete."));
return 2;
}
const fs::path planFile = argv[1];
const fs::path installDir = argv[2];
const fs::path stagingDir = argv[3];
const fs::path backupDir = argv[4];
const std::wstring updater = argv[5];
const DWORD updaterPid = static_cast<DWORD>(_wtoi(argv[6]));
std::vector<std::wstring> updateArgs{argv[7], argv[8], argv[9], argv[10]};
const std::wstring mode = argc >= 12 ? argv[11] : L"install";
LocalFree(argv);
if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
WaitForSingleObject(process, 30000);
CloseHandle(process);
}
const QString planFile = arguments.at(1);
const QString installDir = arguments.at(2);
const QString stagingDir = arguments.at(3);
const QString backupDir = arguments.at(4);
const QString updater = arguments.at(5);
const QString updaterPid = arguments.at(6);
Q_UNUSED(updaterPid);
const QStringList updateArgs{arguments.at(7), arguments.at(8), arguments.at(9), arguments.at(10)};
const QString mode = arguments.size() >= 12 ? arguments.at(11) : QStringLiteral("install");
struct PlanItem { wchar_t operation; fs::path relative; };
std::ifstream input(planFile, std::ios::binary);
std::vector<PlanItem> items;
std::vector<fs::path> paths;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
MessageBoxW(nullptr, L"The update plan operation format is invalid.", L"Update Handoff Failed", MB_ICONERROR);
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;
}
const fs::path relative(fromUtf8(line.substr(2)));
if (!safeRelative(relative)) {
MessageBoxW(nullptr, L"The update plan contains an unsafe path.", L"Update Handoff Failed", MB_ICONERROR);
return 3;
}
items.push_back({static_cast<wchar_t>(line[0]), relative});
paths.push_back(relative);
}
bool success = input.eof();
bool success = true;
bool rolledBack = false;
fs::path failedPath;
if (mode == L"rollback") {
rolledBack = success && rollback(installDir, backupDir, paths);
QString failedPath;
if (mode == QStringLiteral("rollback")) {
rolledBack = rollback(installDir, backupDir, paths);
success = false;
} else if (success) {
for (const auto& item : items) {
const fs::path& relative = item.relative;
if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) {
} else {
// Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
// 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
for (const PlanItem& item : items) {
const QString& relativePath = item.relativePath;
if (isBootstrapSelfPath(relativePath)) {
success = false;
failedPath = relative;
failedPath = relativePath;
break;
}
const bool itemOk = item.operation == L'C'
? copyWithRetry(stagingDir / relative, installDir / relative)
: removeWithRetry(installDir / relative);
const bool itemOk = item.operation == QLatin1Char('C')
? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath))
: removeWithRetry(joinPath(installDir, relativePath));
if (!itemOk) {
success = false;
failedPath = relative;
failedPath = relativePath;
break;
}
}
if (!success) rolledBack = rollback(installDir, backupDir, paths);
if (!success)
rolledBack = rollback(installDir, backupDir, paths);
}
const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
const QString result = success ? QStringLiteral("success")
: (rolledBack ? QStringLiteral("rolledback") : QStringLiteral("rollback-failed"));
if (!launchUpdater(updater, updateArgs, result)) {
std::wstring message = L"Cannot restart Updater.exe.";
if (!failedPath.empty()) message += L"\nFailed file: " + failedPath.wstring();
MessageBoxW(nullptr, message.c_str(), L"Update Handoff Failed", MB_ICONERROR);
QString message = QApplication::translate("Bootstrap", "Cannot restart Updater.");
if (!failedPath.isEmpty()) {
message += QApplication::translate("Bootstrap", "\nFailed file: %1")
.arg(failedPath);
}
showError(message);
return 4;
}
return (success || rolledBack) ? 0 : 5;
+40 -24
View File
@@ -27,6 +27,7 @@ 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"
@@ -55,36 +56,50 @@ add_custom_target(update_client_lupdate
)
# Local-only third-party dependencies. The thirdparty directory is ignored by Git.
# Override OpenSSL when it is not copied into thirdparty:
# -DSIMCAE_OPENSSL_ROOT=<OpenSSL-Win64 root>
# 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")
set(SIMCAE_OPENSSL_ROOT "${THIRDPARTY_DIR}/OpenSSL-Win64" CACHE PATH "OpenSSL Win64 root")
# OpenSSL manual config. The project expects the Windows OpenSSL layout.
if(NOT EXISTS "${SIMCAE_OPENSSL_ROOT}/include/openssl")
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}")
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()
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)
# 统一输出目录
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
# 统一输出目录。Visual Studio Release 实际输出到 out/bin/ReleaseLinux 单独输出到 out/linux/bin。
if(UNIX AND NOT APPLE)
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out/linux)
else()
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out)
endif()
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库
# Bootstrap 使用 Qt 实现跨平台更新交接,避免直接依赖 Win32 API。
add_subdirectory(Bootstrap)
# 先编译公共库
@@ -98,17 +113,20 @@ add_subdirectory(MainApp)
# 默认启动项目
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# Copy config templates and static resources to output bin folder.
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
# Copy local-only static resources to output bin folder. Final customer
# app_config.json is generated by the Go server when a release package is
# uploaded. Developers may create an untracked config/app_config.local.json for
# local debugging.
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
set(LOCAL_APP_CONFIG_FILE "${CONFIG_SOURCE_DIR}/app_config.local.json")
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
if(EXISTS "${LOCAL_APP_CONFIG_FILE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
configure_file("${LOCAL_APP_CONFIG_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
endif()
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
if(EXISTS "${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
@@ -130,11 +148,9 @@ add_dependencies(MainApp copy_client_resources)
add_dependencies(Launcher update_client_translations)
add_dependencies(Updater update_client_translations)
add_dependencies(MainApp update_client_translations)
add_dependencies(Bootstrap update_client_translations)
# Install rules for packaging
if(EXISTS "${APP_CONFIG_SOURCE_FILE}")
install(FILES "${APP_CONFIG_SOURCE_FILE}" DESTINATION bin/config RENAME app_config.json)
endif()
if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
+42
View File
@@ -19,6 +19,20 @@
"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",
@@ -38,6 +52,24 @@
"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": [
@@ -52,6 +84,16 @@
"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"
}
]
}
+3 -8
View File
@@ -13,10 +13,10 @@ set(SRC
LocalStateHelper.cpp
TicketHelper.h
TicketHelper.cpp
UpdatePathPolicy.h
UpdatePathPolicy.cpp
IntegrityHelper.h
IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
)
add_library(Common STATIC ${SRC})
@@ -26,14 +26,9 @@ target_include_directories(Common
PRIVATE ${OPENSSL_INC}
)
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
target_link_directories(Common INTERFACE
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}>
)
target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
INTERFACE libssl.lib libcrypto.lib
PUBLIC ${SIMCAE_OPENSSL_LINK_LIBRARIES}
)
if(WIN32)
+238 -21
View File
@@ -13,6 +13,7 @@
#include <QMessageBox>
#include <QSaveFile>
#include <QSettings>
#include <QStandardPaths>
#include <QStringList>
#include <QDebug>
#include <string>
@@ -34,8 +35,8 @@ 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 kRegistryOrganization = QStringLiteral("SimCAE");
const QString kRegistryApplication = QStringLiteral("HubUpdateClient");
const QString kRegistryInstallationsGroup = QStringLiteral("installations");
const QString kRegistryConfigGroup = QStringLiteral("config");
const QString kRegistryMetaGroup = QStringLiteral("_meta");
@@ -43,7 +44,11 @@ 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(
@@ -169,6 +174,45 @@ bool writeConfigValueToFile(const QString& configPath, const QString& key,
return writeBytesToFile(configPath, payload, errorMessage);
}
bool isRegistryManagedConfigKey(const QString& key)
{
// 服务端地址是编译期 qrc 配置,不进入注册表。
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
Q_UNUSED(key);
return true;
}
bool isPathInsideDirectory(const QString& path, const QString& directory)
{
if (path.isEmpty() || directory.isEmpty())
return false;
QString normalizedPath = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
QString normalizedDirectory = QDir::cleanPath(QFileInfo(directory).absoluteFilePath());
#ifdef Q_OS_WIN
normalizedPath = normalizedPath.toLower();
normalizedDirectory = normalizedDirectory.toLower();
#endif
return normalizedPath == normalizedDirectory
|| normalizedPath.startsWith(normalizedDirectory + QDir::separator());
}
bool isUserDataPath(const QString& path)
{
return isPathInsideDirectory(path, ConfigHelper::instance().dataRoot());
}
QJsonObject registryManagedConfigObject(const QJsonObject& source)
{
QJsonObject result;
for (auto it = source.constBegin(); it != source.constEnd(); ++it)
{
if (isRegistryManagedConfigKey(it.key()))
result.insert(it.key(), it.value());
}
return result;
}
#ifdef Q_OS_WIN
QString windowsErrorMessage(DWORD errorCode)
{
@@ -180,10 +224,12 @@ QString windowsErrorMessage(DWORD errorCode)
bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetPath,
const QString& originalError, QString* errorMessage)
{
// 安装到 C:\Program Files 等目录时,普通用户不能直接修改配置或运行态文件。
// 这里不让主进程一直以管理员运行,而是在确实需要写入时临时拉起自身完成单次写入。
const QMessageBox::StandardButton choice = QMessageBox::question(
nullptr,
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
QCoreApplication::translate("ConfigHelper", "The current installation directory requires administrator permission to save configuration.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.")
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);
@@ -284,6 +330,26 @@ ConfigHelper& ConfigHelper::instance()
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();
@@ -359,6 +425,12 @@ bool ConfigHelper::writeFileWithElevationIfNeeded(const QString& path, const QBy
}
#ifdef Q_OS_WIN
if (isUserDataPath(path))
{
if (errorMessage)
*errorMessage = localError;
return false;
}
if (data.size() > 24 * 1024)
{
if (errorMessage)
@@ -385,6 +457,12 @@ bool ConfigHelper::removeFileWithElevationIfNeeded(const QString& path, QString*
}
#ifdef Q_OS_WIN
if (isUserDataPath(path))
{
if (errorMessage)
*errorMessage = localError;
return false;
}
return removeFileWithElevation(path, localError, errorMessage);
#else
if (errorMessage)
@@ -424,9 +502,38 @@ QString ConfigHelper::runtimeRoot() const
return QDir::cleanPath(QApplication::applicationDirPath());
}
QString ConfigHelper::dataRoot() const
{
QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
if (base.isEmpty())
base = QDir::homePath();
return QDir::cleanPath(QDir(base).filePath(
QStringLiteral("SimCAE/HubUpdateClient/installations/%1").arg(m_registryInstallId)));
}
QString ConfigHelper::dataConfigDir() const
{
return QDir(dataRoot()).filePath(QStringLiteral("config"));
}
QString ConfigHelper::clientIdentityPath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("client_identity.dat"));
}
QString ConfigHelper::policyPath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("version_policy.dat"));
}
QString ConfigHelper::localStatePath() const
{
return QDir(dataConfigDir()).filePath(QStringLiteral("local_state.json"));
}
QString ConfigHelper::updateRoot() const
{
return QDir(runtimeRoot()).filePath("update");
return QDir(dataRoot()).filePath("update");
}
QString ConfigHelper::runtimeRelativePath() const
@@ -472,7 +579,8 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
return false;
}
const QByteArray normalizedConfig = QJsonDocument(document.object()).toJson(QJsonDocument::Compact);
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());
@@ -486,11 +594,29 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
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());
const QJsonObject config = document.object();
for (auto it = config.constBegin(); it != config.constEnd(); ++it)
settings.setValue(it.key(), it.value().toVariant());
settings.endGroup();
@@ -514,26 +640,97 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
if (configChangedAfterPreviousImport)
{
const QString configDir = QFileInfo(m_configPath).absolutePath();
const QStringList staleFiles{
QDir(configDir).filePath(QStringLiteral("client_identity.dat")),
QDir(configDir).filePath(QStringLiteral("version_policy.dat"))
clientIdentityPath(),
policyPath(),
localStatePath()
};
for (const QString& staleFile : staleFiles)
{
QString removeError;
if (!ConfigHelper::removeFileWithElevationIfNeeded(staleFile, &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 and policy after app_config.json changed.";
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,
@@ -573,6 +770,9 @@ bool ConfigHelper::writeRegistryValue(const QString& key, const QString& value)
QString ConfigHelper::readFileValue(const QString& key) const
{
if (!isRegistryManagedConfigKey(key))
return QString();
QFile file(m_configPath);
if (!file.open(QIODevice::ReadOnly))
return QString();
@@ -588,10 +788,18 @@ QString ConfigHelper::readFileValue(const QString& key) const
QString ConfigHelper::getValue(const QString& section, const QString& key) const
{
Q_UNUSED(section);
if (!isRegistryManagedConfigKey(key))
return QString();
QString value;
if (readRegistryValue(key, &value))
return value;
return readFileValue(key);
value = readFileValue(key).trimmed();
if (!value.isEmpty())
return value;
return readEmbeddedValue(key);
}
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
@@ -619,25 +827,34 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
};
copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App");
copyText("App", "product_code", ini.value("App/app_id").toString());
copyText("App", "app_name", "SimCAE");
copyText("App", "channel", "stable");
copyText("App", "current_version", "1.0.0");
copyText("App", "client_protocol", "3");
copyText("App", "launch_token");
copyText("License", "license_key");
copyText("Server", "api_base_url");
copyText("Server", "client_token");
copyText("App", "launch_token");
copyText("Server", "api_base_url");
copyText("Update", "request_timeout_ms", "5000");
copyText("Update", "temp_folder", "update_temp");
copyText("Update", "device_id");
copyText("Runtime", "install_root", ".");
copyText("Runtime", "main_executable", "MainApp.exe");
copyText("Runtime", "launcher_executable", "Launcher.exe");
copyText("Runtime", "updater_executable", "Updater.exe");
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
copyText("Runtime", "main_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "MainApp"));
copyText("Runtime", "launcher_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Launcher"));
copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater"));
copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap"));
copyText("Runtime", "health_check_timeout_ms", "15000");
copyText("Security", "require_manifest_signature", "false");
copyText("Security", "verify_installed_on_start", "false");
copyText("Platform", "abi");
#ifdef Q_OS_WIN
config.insert("platform", "windows");
config.insert("arch", "x64");
#elif defined(Q_OS_LINUX)
config.insert("platform", "linux");
#else
config.insert("platform", "unknown");
#endif
config.insert("arch", "x86_64");
QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath);
+10
View File
@@ -9,6 +9,8 @@ class ConfigHelper
public:
static ConfigHelper& instance();
static int runElevatedWriteCommandIfRequested();
static QString executableNameForCurrentPlatform(const QString& configuredValue,
const QString& fallbackBaseName);
static bool writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
QString* errorMessage = nullptr);
static bool removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage = nullptr);
@@ -17,6 +19,11 @@ public:
QString configPath() const;
QString installRoot() const;
QString runtimeRoot() const;
QString dataRoot() const;
QString dataConfigDir() const;
QString clientIdentityPath() const;
QString policyPath() const;
QString localStatePath() const;
QString updateRoot() const;
QString runtimeRelativePath() const;
QString lastError() const;
@@ -26,6 +33,9 @@ private:
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;
-48
View File
@@ -1,48 +0,0 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSysInfo>
#include <QUuid>
#include <QTimer>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
const QString trimmedBase=base.trimmed();
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
ConfigHelper& config=ConfigHelper::instance();
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);QString writeError;if(!ConfigHelper::writeFileWithElevationIfNeeded(path,bytes,&writeError)){m_error=QString("cannot save device credential to %1: %2").arg(path,writeError);return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
}
-15
View File
@@ -1,15 +0,0 @@
#pragma once
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error;
};
+61 -6
View File
@@ -1,5 +1,20 @@
#include "FileHelper.h"
#include <QDebug>
#include <QFileInfo>
#include <QThread>
namespace
{
QString processImageName(const QString& exeName)
{
QString name = QFileInfo(exeName).fileName().trimmed();
#ifndef Q_OS_WIN
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
name.chop(4);
#endif
return name;
}
}
bool FileHelper::createDir(const QString &path)
{
@@ -24,19 +39,59 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
bool FileHelper::isProcessRunning(const QString &exeName)
{
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return false;
QProcess process;
process.start("tasklist");
#ifdef Q_OS_WIN
process.start(QStringLiteral("tasklist"), QStringList{
QStringLiteral("/FI"),
QStringLiteral("IMAGENAME eq %1").arg(imageName)
});
process.waitForFinished();
QString output = process.readAllStandardOutput();
return output.contains(exeName, Qt::CaseInsensitive);
const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
return output.contains(imageName, Qt::CaseInsensitive);
#elif defined(Q_OS_UNIX)
process.start(QStringLiteral("pgrep"), QStringList{
QStringLiteral("-x"),
imageName
});
process.waitForFinished(1000);
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
#else
return false;
#endif
}
bool FileHelper::killProcess(const QString &exeName)
{
if (!isProcessRunning(exeName))
return true;
const QString imageName = processImageName(exeName);
if (imageName.isEmpty())
return true;
QProcess process;
process.start("taskkill /f /im " + exeName);
process.waitForFinished(1000);
return !isProcessRunning(exeName);
#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;
}
+103 -36
View File
@@ -4,33 +4,53 @@
#include <QFile>
#include <QDir>
#include <QApplication>
#include <QJsonParseError>
#include <QTimer>
#include <QUrl>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
namespace {
int requestTimeoutMs()
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
QEventLoop loop;
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
if (!timeoutOk || timeoutMs < 1000)
timeoutMs = 5000;
return timeoutMs;
}
void applyCommonHeaders(QNetworkRequest& req, const QString& bearerToken)
{
const QString clientToken = ConfigHelper::instance().getValue(QStringLiteral("Server"), QStringLiteral("client_token")).trimmed();
if (!clientToken.isEmpty())
req.setRawHeader("X-Client-Token", clientToken.toUtf8());
const QString token = bearerToken.trimmed();
if (!token.isEmpty())
req.setRawHeader("Authorization", QByteArray("Bearer ") + token.toUtf8());
}
void readJsonReply(QNetworkReply* reply, int* retCode, QJsonObject* retObj)
{
*retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(respData, &parseError);
if (parseError.error == QJsonParseError::NoError && document.isObject())
*retObj = document.object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << *retCode << "detail:" << reply->errorString();
}
}
void waitForReply(const QString& url, QNetworkReply* reply)
{
QEventLoop loop;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
@@ -39,26 +59,73 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
timer.start(requestTimeoutMs());
loop.exec();
timer.stop();
}
} // namespace
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
postRequest(url, jsonBody, QString(), callback);
}
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
applyCommonHeaders(req, bearerToken);
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
waitForReply(url, reply);
int retCode = 0;
QJsonObject retObj;
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
retObj = QJsonDocument::fromJson(respData).object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
}
readJsonReply(reply, &retCode, &retObj);
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
}
void HttpHelper::getRequest(const QString& url,
std::function<void(int code, const QJsonObject& resp)> callback)
{
getRequest(url, QString(), callback);
}
void HttpHelper::getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req{QUrl(url)};
applyCommonHeaders(req, bearerToken);
qDebug() << "=== GET Request ===";
qDebug() << "Url:" << url;
QNetworkReply* reply = manager->get(req);
waitForReply(url, reply);
int retCode = 0;
QJsonObject retObj;
readJsonReply(reply, &retCode, &retObj);
callback(retCode, retObj);
+8
View File
@@ -7,6 +7,7 @@
#include <QJsonDocument>
#include <QEventLoop>
#include <QDebug>
#include <functional>
class HttpHelper
{
@@ -14,4 +15,11 @@ public:
// Create an independent manager for each call instead of keeping it as a member.
static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback);
static void postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
};
+162 -38
View File
@@ -1,11 +1,13 @@
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
@@ -14,6 +16,32 @@
#include <openssl/pem.h>
#endif
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
bool manifestFileRequired(const QJsonObject& item)
{
if (!item.contains(QStringLiteral("required")))
return true;
return item.value(QStringLiteral("required")).toBool(true);
}
} // namespace
IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {}
@@ -21,28 +49,13 @@ QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::safeRelativePath(const QString& path) const
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
return UpdatePathPolicy::isSafeRelativePath(path);
}
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(p);
return UpdatePathPolicy::isFullUpdateProtectedPath(
path, ConfigHelper::instance().runtimeRelativePath());
}
QString IntegrityHelper::sha256(const QString& filePath) const
@@ -57,18 +70,31 @@ QString IntegrityHelper::sha256(const QString& filePath) const
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{
#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
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
if (!QFile::exists(keyPath))
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) { m_error = "manifest public key invalid"; return false; }
if (!key) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
@@ -76,7 +102,10 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) m_error = "manifest RSA signature invalid";
if (!ok) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
}
return ok;
#endif
}
@@ -85,6 +114,8 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
const QString& version)
{
m_error.clear();
// Manifest cache 来自服务端发布版本时生成的签名清单。
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
"manifest_cache/manifest_" + version + ".json");
const QString legacyCachePath = QDir(m_installDir).filePath(
@@ -92,48 +123,130 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
if (!QFile::exists(cachePath))
cachePath = legacyCachePath;
QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
if (!cache.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is missing. Stage: installed version verification. Version: %1. Expected cache file: %2. This cache is created after the same version is published or installed successfully.")
.arg(version, cachePath);
return false;
}
QJsonParseError wrapperError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = "manifest cache JSON invalid"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, wrapperError.errorString());
return false;
}
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false;
QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
if (manifestText.isEmpty())
manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString manifestSha256 = wrapper.value("manifestSha256").toString(
wrapper.value("manifest_sha256").toString());
const QString signature = wrapper.value("signature").toString(
wrapper.value("manifest").toObject().value("signature").toString());
const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty());
if (manifestText.isEmpty()) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath);
return false;
}
if (!manifestSha256.isEmpty()) {
const QString actualSha = QString::fromLatin1(
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
.arg(version, manifestSha256, actualSha);
return false;
}
}
if (signedManifest && !signature.isEmpty()) {
if (!verifySignature(manifestText, signature)) return false;
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
.arg(version);
return false;
}
QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
m_error = "signed manifest payload invalid"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
.arg(version, cachePath, manifestError.errorString());
return false;
}
const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId
const QString manifestProduct = manifest.value("productCode").toString(
manifest.value("app_id").toString());
if (manifestProduct != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = "manifest identity does not match local application"; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifestProduct,
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QSet<QString> declaredExecutables;
QSet<QString> optionalComponentDirs;
for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; }
if (!safeRelativePath(path)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
.arg(version, path);
return false;
}
if (UpdatePathPolicy::isExecutableOrLibrary(path))
declaredExecutables.insert(path.toCaseFolded());
if (!manifestFileRequired(item)) {
const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path());
if (!dir.isEmpty() && dir != QStringLiteral("."))
optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded());
continue;
}
if (runtimeProtectedPath(path)) continue;
const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; }
if (!QFile::exists(fullPath)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"A required installed file is missing. Stage: installed version verification. Version: %1. Manifest path: %2. Checked path: %3. The local installation no longer matches the published version.")
.arg(version, path, fullPath);
return false;
}
const qint64 expectedSize = item.contains("sizeBytes")
? item.value("sizeBytes").toVariant().toLongLong()
: item.value("size").toVariant().toLongLong();
if ((item.contains("sizeBytes") || item.contains("size"))
&& QFileInfo(fullPath).size() != expectedSize) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes")
.arg(version, path, fullPath,
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
return false;
}
const QString expected = item.value("sha256").toString();
const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
m_error = "file hash mismatch: " + path; return false;
m_error = QCoreApplication::translate("IntegrityHelper",
"Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected SHA-256: %4\nActual SHA-256: %5\nThis means the installed file is different from the version that was published or installed. If this is a developer test machine, check whether the local Release directory was recompiled or overwritten after publishing.")
.arg(version, path, fullPath, expected,
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
return false;
}
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
}
QDir root(m_installDir);
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
// 这能降低被人偷偷塞插件或可执行文件的风险。
while (it.hasNext()) {
const QString fullPath = it.next();
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
@@ -144,9 +257,20 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|| folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = "undeclared executable or plugin: " + relative; return false;
bool optionalComponentFile = false;
for (const QString& prefix : optionalComponentDirs) {
if (folded.startsWith(prefix)) {
optionalComponentFile = true;
break;
}
}
if (optionalComponentFile)
continue;
if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
return false;
}
}
return true;
+28 -6
View File
@@ -1,16 +1,24 @@
#include "LocalStateHelper.h"
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
LocalStateHelper::LocalStateHelper(const QString& baseDir)
: m_baseDir(baseDir)
, m_filePath(baseDir + "/config/local_state.json")
, m_filePath(ConfigHelper::instance().localStatePath())
{
}
bool LocalStateHelper::loadState(const QString& relativePath)
{
const QString defaultState = QStringLiteral("config/local_state.json");
if (relativePath == defaultState)
m_filePath = ConfigHelper::instance().localStatePath();
else if (QDir::isAbsolutePath(relativePath))
m_filePath = relativePath;
else
m_filePath = m_baseDir + "/" + relativePath;
QFile file(m_filePath);
if (!file.exists())
@@ -24,7 +32,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
m_loaded = true;
if (!saveState())
{
m_error = QString("Cannot write new state file: %1").arg(m_filePath);
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot create local state file: %1. Error: %2.")
.arg(m_filePath, m_error);
return false;
}
return true;
@@ -32,7 +43,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
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;
}
@@ -43,7 +57,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
{
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString());
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state file is not valid JSON. File: %1. JSON error: %2.")
.arg(m_filePath, parseError.errorString());
return false;
}
@@ -56,7 +73,9 @@ bool LocalStateHelper::saveState() const
{
if (!m_loaded)
{
m_error = "State is not loaded";
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Local state has not been loaded.");
return false;
}
@@ -64,7 +83,10 @@ bool LocalStateHelper::saveState() const
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
{
m_error = QString("Cannot save state file: %1").arg(writeError);
m_error = QCoreApplication::translate(
"LocalStateHelper",
"Cannot save local state file: %1. Error: %2.")
.arg(m_filePath, writeError);
return false;
}
m_error.clear();
+81 -12
View File
@@ -1,7 +1,9 @@
#include "PolicyHelper.h"
#include "ConfigHelper.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
@@ -13,14 +15,38 @@
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
static QString resolvePolicyPath(const QString& baseDir, const QString& relativePath, bool forWrite)
{
const QString defaultPolicy = QStringLiteral("config/version_policy.dat");
if (relativePath == defaultPolicy) {
const QString runtimePolicy = ConfigHelper::instance().policyPath();
if (forWrite || QFile::exists(runtimePolicy))
return runtimePolicy;
}
if (QDir::isAbsolutePath(relativePath))
return relativePath;
return baseDir + "/" + relativePath;
}
bool PolicyHelper::loadPolicy(const QString& relativePath)
{
QFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open signed version policy file: %1. Error: %2.")
.arg(path, file.errorString());
return false;
}
QJsonParseError error;
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
m_error = "Invalid policy JSON: " + error.errorString(); return false;
m_error = QCoreApplication::translate(
"PolicyHelper",
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
.arg(path, error.errorString());
return false;
}
return loadPolicyObject(doc.object());
}
@@ -35,8 +61,12 @@ bool PolicyHelper::savePolicy(const QString& relativePath) const
{
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_baseDir + "/" + relativePath, bytes, &writeError)) {
m_error = "Cannot save policy file: " + 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();
@@ -70,34 +100,72 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"PolicyHelper",
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
return false;
#else
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; }
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Cannot open version policy public key: %1. Error: %2.")
.arg(keyPath, keyFile.errorString());
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) { m_error = "Invalid policy public key"; return false; }
if (!key) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy public key is invalid: %1.")
.arg(keyPath);
return false;
}
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) m_error = "Invalid RSA policy signature";
if (!ok) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
}
return ok;
#endif
}
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",
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; }
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
for (const QString& key : required) {
if (!m_policy.contains(key)) {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy is missing required field: %1.")
.arg(key);
return false;
}
}
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
m_error = QCoreApplication::translate(
"PolicyHelper",
"Version policy signature algorithm is unsupported: %1.")
.arg(m_policy.value("signature_alg").toString());
return false;
}
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
return verifySignature(payload, m_policy.value("signature").toString());
}
@@ -111,6 +179,7 @@ bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
bool PolicyHelper::gitTagsEnabled() const { return isValid() && m_policy.value("git_tags_enabled").toBool(); }
bool PolicyHelper::isExpired() const {
if (!isValid()) return true;
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
+1
View File
@@ -17,6 +17,7 @@ public:
bool forceUpdate() const;
bool allowRollback() const;
bool isOfflineAllowed() const;
bool gitTagsEnabled() const;
bool isExpired() const;
qint64 policySeq() const;
QString message() const;
+103 -13
View File
@@ -8,6 +8,7 @@
#include <QMessageAuthenticationCode>
#include <QSaveFile>
#include <QStandardPaths>
#include <QStringList>
#include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
@@ -20,8 +21,20 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage)
{
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
// Launcher 启动业务主程序前生成一次性 ticket。
// 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;
}
const QDateTime now = QDateTime::currentDateTimeUtc();
@@ -34,12 +47,25 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
};
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
if (!QDir().mkpath(dirPath)) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot create launch ticket directory: %1.")
.arg(dirPath);
}
return false;
}
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
QSaveFile file(path);
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
if (errorMessage) *errorMessage = "cannot save ticket";
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot save launch ticket file: %1. Error: %2.")
.arg(path, file.errorString());
}
return false;
}
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
@@ -51,16 +77,28 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage)
{
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
const QString consumingPath = ticketPath + ".consuming."
+ QString::number(QCoreApplication::applicationPid());
if (!QFile::rename(ticketPath, consumingPath)) {
if (errorMessage) *errorMessage = "ticket missing or already consumed";
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket is missing or has already been consumed. Ticket file: %1. Please start the application from Launcher.")
.arg(ticketPath);
}
return false;
}
QFile file(consumingPath);
if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(consumingPath);
if (errorMessage) *errorMessage = "cannot read claimed ticket";
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Cannot read claimed launch ticket: %1. Error: %2.")
.arg(consumingPath, file.errorString());
}
return false;
}
const QByteArray raw = file.readAll(); file.close();
@@ -68,7 +106,13 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
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 payload = wrapper.value("payload").toObject();
@@ -77,14 +121,60 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
const QDateTime now = QDateTime::currentDateTimeUtc();
const bool identityOk = payload.value("app_id").toString() == expectedAppId
&& payload.value("device_id").toString() == expectedDeviceId
&& payload.value("version").toString() == expectedVersion;
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
&& expires >= now && issued.secsTo(expires) <= 65;
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk
|| payload.value("nonce").toString().isEmpty()) {
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
if (actual.isEmpty() || actual != expected) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket signature is invalid. The launch_token used by Launcher and the main application is inconsistent, or the ticket content was changed.");
}
return false;
}
if (payload.value("app_id").toString() != expectedAppId) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket app_id does not match. Ticket app_id: %1. Expected app_id: %2.")
.arg(payload.value("app_id").toString(), expectedAppId);
}
return false;
}
if (payload.value("device_id").toString() != expectedDeviceId) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket device_id does not match. Ticket device_id: %1. Expected device_id: %2. Reauthorize the device from Launcher if the configuration was regenerated.")
.arg(payload.value("device_id").toString(), expectedDeviceId);
}
return false;
}
if (payload.value("version").toString() != expectedVersion) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket version does not match. Ticket version: %1. Expected version: %2.")
.arg(payload.value("version").toString(), expectedVersion);
}
return false;
}
if (!timeOk) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket time is invalid or expired. Issued at: %1. Expires at: %2. Current UTC time: %3.")
.arg(payload.value("issued_at").toString(),
payload.value("expires_at").toString(),
now.toString(Qt::ISODate));
}
return false;
}
if (payload.value("nonce").toString().isEmpty()) {
if (errorMessage) {
*errorMessage = QCoreApplication::translate(
"TicketHelper",
"Launch ticket nonce is missing. The ticket is incomplete.");
}
return false;
}
return true;
+127
View File
@@ -0,0 +1,127 @@
#include "UpdatePathPolicy.h"
#include <QDir>
#include <QFileInfo>
#include <QSet>
#include <QStringList>
namespace {
bool exactOrRuntimeMatch(const QString& folded, const QString& runtimePrefix,
const QSet<QString>& exactPaths)
{
if (exactPaths.contains(folded))
return true;
if (runtimePrefix.isEmpty() || !folded.startsWith(runtimePrefix + QStringLiteral("/")))
return false;
return exactPaths.contains(folded.mid(runtimePrefix.size() + 1));
}
bool prefixOrRuntimePrefixMatch(const QString& folded, const QString& runtimePrefix,
const QString& prefix)
{
if (folded.startsWith(prefix))
return true;
if (runtimePrefix.isEmpty())
return false;
return folded.startsWith(runtimePrefix + QStringLiteral("/") + prefix);
}
} // namespace
namespace UpdatePathPolicy {
QString normalizeRelativePath(const QString& path)
{
QString normalized = QDir::cleanPath(QDir::fromNativeSeparators(path.trimmed()));
if (normalized == QStringLiteral("."))
return QString();
while (normalized.startsWith(QStringLiteral("./")))
normalized = normalized.mid(2);
return normalized;
}
bool isSafeRelativePath(const QString& path)
{
const QString clean = normalizeRelativePath(path);
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != QStringLiteral("..")
&& !clean.startsWith(QStringLiteral("../")) && !clean.contains(QLatin1Char(':'));
}
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath)
{
const QString folded = normalizeRelativePath(path).toCaseFolded();
const QString runtimePrefix = normalizeRelativePath(runtimeRelativePath).toCaseFolded();
const QSet<QString> exactPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
if (exactOrRuntimeMatch(folded, runtimePrefix, exactPaths))
return true;
return prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update/"))
|| prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update_temp/"));
}
bool isIFWInstallerManagedPath(const QString& path)
{
const QString folded = normalizeRelativePath(path).toCaseFolded();
if (folded.isEmpty())
return false;
const bool rootFile = !folded.contains(QLatin1Char('/'));
if (rootFile && (folded == QStringLiteral("maintenancetool")
|| folded == QStringLiteral("maintenancetool.exe")
|| folded.startsWith(QStringLiteral("maintenancetool.")))) {
return true;
}
const QSet<QString> exactPaths{
QStringLiteral("components.xml"),
QStringLiteral("components.xml.new"),
QStringLiteral("components.xml.old"),
QStringLiteral("installation.xml"),
QStringLiteral("installation.dat"),
QStringLiteral("installer.dat"),
QStringLiteral("installer.ini"),
QStringLiteral("network.xml"),
QStringLiteral("repositories.xml"),
QStringLiteral("repositories.cfg"),
QStringLiteral("repository.xml")
};
if (exactPaths.contains(folded))
return true;
const QStringList prefixes{
QStringLiteral("installerresources/"),
QStringLiteral("installationinformation/"),
QStringLiteral("licenses/")
};
for (const QString& prefix : prefixes) {
if (folded.startsWith(prefix))
return true;
}
return false;
}
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath)
{
return isUpdaterRuntimeProtectedPath(path, runtimeRelativePath)
|| isIFWInstallerManagedPath(path);
}
bool isExecutableOrLibrary(const QString& path)
{
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
return suffix == QStringLiteral("exe") || suffix == QStringLiteral("dll");
}
} // namespace UpdatePathPolicy
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <QString>
namespace UpdatePathPolicy {
QString normalizeRelativePath(const QString& path);
bool isSafeRelativePath(const QString& path);
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath);
bool isIFWInstallerManagedPath(const QString& path);
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath);
bool isExecutableOrLibrary(const QString& path);
}
-47
View File
@@ -1,47 +0,0 @@
客户端文档入口
==============
本目录用于保存 update-client 客户端子仓库的说明文档。
建议阅读顺序:
1. 客户端部署说明.md
面向接入和部署,说明 SDK 是什么、怎么放到业务软件目录、app_config.json 怎么填、如何打包。
2. 第三方依赖说明.md
面向编译环境,说明 Qt、OpenSSL、thirdparty/ 和 CMake 环境变量怎么配置。
3. 本 ReadMe.txt
记录客户端配置文件和打包脚本的简要说明。
客户端配置说明
==============
config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前 Windows 用户的注册表,后续运行配置优先从注册表读取。
如果 config/app_config.json 内容被修改,下一次启动时会按解析后的 JSON 内容 SHA256 判断变化并重新导入注册表。
注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。
如果检测到 app_config.json 发生变化,SDK 会删除 config/client_identity.dat 和 config/version_policy.dat,避免继续使用旧授权身份或旧策略;目录不可写时会弹出管理员权限确认框。
首次运行时如果该文件不存在且发现旧 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。
完整格式参考 update-client/config/app_config.example.json。
运行时生成的 client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。app_config.json 可以作为部署模板,但不要把某台机器运行后产生的临时状态混进去。
Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
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 的发布源目录。
-299
View File
@@ -1,299 +0,0 @@
# UpdateClientSDK 客户端部署说明
本文按“维护者打包 SDK -> 你接入业务软件 -> 联调测试 -> 生成最终客户端包”的顺序说明。你拿到这份文档后,按章节一步一步做即可。
## 一、这个 SDK 是什么
UpdateClientSDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力做成一组独立程序,让业务软件通过这些程序完成检查更新、下载、安装、回滚和启动保护。
SDK 核心程序:
- `Launcher.exe`:用户入口。检查版本、验证授权和策略,决定直接启动业务主程序或进入升级流程。
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe`:处理运行中可能被占用的 EXE/DLL 替换。
- `config/app_config.json`:部署配置源文件。启动时会同步到当前 Windows 用户的注册表,运行时优先读注册表。
- `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. SDK Word 接入说明已经放在 `update-client` 根目录或 `update-client/Docs` 目录下,文件名包含 `SDK`,例如 `SimCAE自动升级SDK接入说明_v0.1.docx`。打包脚本会把它复制到 SDK 根目录,方便接入方一打开压缩包就能看到。
4. 如果业务软件本身已经带 Qt DLL,通常不要把 SDK 的 Qt 运行库打进去,避免 Qt 版本混用。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
生成结果:
```text
dist/
UpdateClientSDK/
SimCAE自动升级SDK接入说明_v0.1.docx
sdk_manifest.json
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
UpdateClientSDK.zip
```
`dist/UpdateClientSDK.zip` 发给接入方即可。
可选参数:
- `-IncludeDemoMainApp`:把仓库里的 Demo 主程序 `MainApp.exe` 也打进 SDK,方便演示。
- `-IncludeQtRuntime`:把 Qt 运行库也打进 SDK。只有业务软件本身不带 Qt 时才建议使用。
## 三、你:把 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`,否则可能出现“无法定位程序输入点”一类错误。
## 四、你:生成并填写 app_config.json
最推荐的方式是在服务端管理后台生成客户端配置:
1. 浏览器打开服务端管理后台,例如 `http://服务器IP:8000/`
2. 登录后台。
3. 创建或选择应用,例如 `app_id=simcae`
4. 创建 License。
5. 在“客户端配置生成”区域选择应用、渠道、License 和主程序名。
6. 点击生成配置。
7. 复制生成的 JSON,覆盖 `D:\SimCAE\bin\config\app_config.json`
配置同步规则:
- `app_config.json` 是部署配置源文件,适合交付、复制、人工修改。
- Launcher / Updater / MainApp 启动时会计算 `app_config.json` 解析后的 JSON 内容 SHA256;如果 JSON 内容和上次导入时不同,就把文件里的配置重新写入当前 Windows 用户的注册表。
- 后续运行时优先从注册表读取配置,不再每次直接读 JSON。
- 运行过程中产生的动态值,例如首次输入的 `license_key`、服务端返回的 `device_id`、升级后的 `current_version`,会写入注册表。
- 如果你手动修改了 `app_config.json`,下一次启动会重新导入并覆盖注册表里的同名字段。
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除 `config/client_identity.dat``config/version_policy.dat`,避免继续使用旧 License、旧设备身份或旧版本策略;如果安装目录不可写,会弹出管理员权限确认框。
- 注册表按安装目录隔离;同一台电脑上多个安装目录不会互相覆盖配置。
- 注册表位置为 `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` 会弹窗让用户输入并保存到注册表;如果授权错误或过期,也会提示重新输入。
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`
- `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",
"api_base_url": "http://192.168.229.128: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"
}
```
## 五、你:业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
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`
## 六、你:准备服务端数据
首次联调前,服务端至少要准备这些内容:
1. 创建应用,例如 `simcae`
2. 创建渠道,例如 `stable`
3. 创建 License。
4. 发布一个初始版本,例如 `1.0.0`
5. 生成客户端配置,并写入 `bin\config\app_config.json`。客户端下次启动时会自动同步到注册表。
为什么必须先发布初始版本:Launcher 启动业务主程序前会做 Manifest 完整性校验。这个 Manifest 是服务端发布版本时生成并签名的清单,用来证明当前本地文件属于一个可信版本。如果没有发布过 `current_version` 对应版本,客户端会提示签名 Manifest 缓存缺失。
后台发布版本时,选择整个安装根目录,例如 `D:\SimCAE\`,不要只选择 `D:\SimCAE\bin`。这样服务端会把 `bin/SimCAE.exe``Licenses/``installerResources/` 等完整结构写进 Manifest。
## 七、你:运行和联调
基础联调步骤:
1. 确认服务端正在运行。
2. 确认 `bin\config\app_config.json``api_base_url``client_token``license_key``current_version` 正确。
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\...` 这类默认不可写目录,普通配置值会写入当前用户注册表,不需要修改 `app_config.json`。但 `client_identity.dat``local_state.json``version_policy.dat` 等本地状态文件仍位于安装目录下;当这些文件需要写入且目录不可写时,SDK 会弹出 Windows 管理员权限确认框,用户点击“是”后会继续保存。
注意:这次提权主要覆盖小型配置/状态文件的写入和删除。更新缓存、离线包暂存、升级替换 EXE/DLL 等大文件操作仍建议放在可写目录;如果最终产品必须完整安装到 `C:\Program Files\SimCAE\bin` 并在普通用户下自动升级,后续建议把运行时状态迁移到 `ProgramData` / `AppData`,或让 Updater/Bootstrap 在替换安装目录文件时走管理员权限。
## 八、维护者:生成某个产品的最终客户端包
SDK 是给接入方开发使用的。最终给用户安装或分发时,可以从已经联调过的 Release 目录生成最终客户端包。
在 Windows PowerShell 中执行:
```powershell
cd C:\Users\admin\Desktop\update-client
.\scripts\package-client.ps1 `
-SourceDir .\out\bin `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
`package-client.ps1` 会检查:
- 配置文件必填字段是否完整。
- 主程序、Launcher、Updater、Bootstrap 是否存在。
- 是否混入 Debug DLL、PDB、ILK。
- 当前版本是否已有签名 Manifest 缓存。
- 是否存在重复主程序。
生成结果:
```text
dist/
UpdateClient/
UpdateClient.zip
```
## 九、常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动保存配置/状态文件失败:如果目录不可写,SDK 会弹出管理员权限确认框;用户取消或当前账号没有管理员权限时仍会失败。
3. 首次启动设备登记失败:检查 `api_base_url``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` 默认是 `bin/SimCAE.exe`,发布目录要选择包含 `bin/SimCAE.exe` 的安装根目录。
9. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`
-117
View File
@@ -1,117 +0,0 @@
# 第三方依赖说明
`thirdparty/` 是本机依赖目录,已经被 `.gitignore` 忽略,不会提交到 Git。
当前客户端构建依赖:
1. Qt 5.15.2 msvc2019_64
2. OpenSSL-Win64
## 1. Qt 配置
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。
## 2. OpenSSL 配置
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"
```
## 3. 重新配置和编译
如果之前配置过 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. 提交注意事项
不要提交下面这些内容:
```text
thirdparty/
.vs/
out/
build/
dist/
App/
*.exe
*.dll
*.lib
*.pdb
```
这些都属于本机依赖、构建产物或打包产物,不应该进 Git。
+2 -5
View File
@@ -15,7 +15,8 @@ set(SRC_LIST
UpdateLogic.h
UpdateLogic.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
)
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
# 生成可执行程序
add_executable(Launcher ${SRC_LIST})
if(WIN32)
@@ -27,10 +28,6 @@ target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
# 链接Common,自动带上OpenSSL库
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
# 输出编译产物到 out 文件夹(干净不乱)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
# 强制VS打开CMake文件夹时默认选中该可执行目标
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
+206 -97
View File
@@ -1,178 +1,287 @@
#include "UpdateLogic.h"
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QDebug>
#include <QJsonArray>
#include <QApplication>
#include <QDir>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include "PolicyHelper.h"
#include "LocalStateHelper.h"
#include <QUrl>
#include <QUrlQuery>
namespace {
QString trimBaseUrl(QString value)
{
value = value.trimmed();
while (value.endsWith(QLatin1Char('/')))
value.chop(1);
return value;
}
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
{
const QString trimmed = value.trimmed();
if (!trimmed.isEmpty())
query.addQueryItem(key, trimmed);
}
QString serverMessage(const QJsonObject& response)
{
const QString msg = response.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty())
return msg;
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject object = detail.toObject();
const QString detailMsg = object.value(QStringLiteral("msg")).toString();
if (!detailMsg.isEmpty())
return detailMsg;
const QString error = object.value(QStringLiteral("error")).toString();
if (!error.isEmpty())
return error;
}
return detail.toString();
}
QJsonObject responseDataObject(const QJsonObject& response)
{
return response.value(QStringLiteral("data")).isObject()
? response.value(QStringLiteral("data")).toObject()
: response;
}
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
} // namespace
UpdateLogic::UpdateLogic(QObject* parent)
: 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");
m_serverAddr = trimBaseUrl(cfg.getValue("Server", "api_base_url"));
m_appId = cfg.getValue("App", "product_code").trimmed();
if (m_appId.isEmpty())
m_appId = cfg.getValue("App", "app_id").trimmed();
m_curVer = cfg.getValue("App", "current_version").trimmed();
m_channel = cfg.getValue("App", "channel").trimmed();
if (m_channel.isEmpty())
m_channel = QStringLiteral("stable");
// Debug print config
qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId;
qDebug() << "Read product code:" << 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_error.clear();
m_needUpdate = false;
m_networkOk = false;
m_lastStatusCode = 0;
m_latestVer.clear();
m_checkResp = QJsonObject();
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty())
{
m_error = QCoreApplication::translate(
"UpdateLogic",
"Update configuration is incomplete. Server, product_code and current_version are required.");
qDebug() << "Config incomplete, abort update check:" << m_error;
return;
}
if (configValue(QStringLiteral("client_token")).isEmpty())
{
m_lastStatusCode = 401;
m_error = QCoreApplication::translate(
"UpdateLogic",
"Update configuration is incomplete. client_token is required.");
m_checkResp.insert(QStringLiteral("msg"), m_error);
qDebug() << "Client token missing, abort update check:" << m_error;
return;
}
QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject body;
body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/authorized-check"));
QUrlQuery query;
addQueryValue(query, QStringLiteral("productCode"), m_appId);
addQueryValue(query, QStringLiteral("currentVersion"), m_curVer);
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
addQueryValue(query, QStringLiteral("channel"), m_channel);
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
url.setQuery(query);
m_http.getRequest(url.toString(QUrl::FullyEncoded),
[this](int code, const QJsonObject& resp)
{
qDebug() << "Check update HTTP code:" << code;
m_lastStatusCode = code;
m_checkResp = resp;
m_networkOk = (code == 200);
if (code == 200)
if (code != 200)
{
const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy())
{
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false;
m_needUpdate = false;
m_error = serverMessage(resp);
qDebug() << "Check update api failed:" << m_error;
return;
}
state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState())
{
qDebug() << "Cannot persist online policy state";
m_networkOk = false;
const QJsonArray releases = resp.value(QStringLiteral("data")).toArray();
QJsonObject selected;
for (const QJsonValue& value : releases) {
const QJsonObject release = value.toObject();
const bool hasPackages = !release.value(QStringLiteral("packages")).toArray().isEmpty();
const bool hasManifest = release.value(QStringLiteral("manifest")).isObject()
|| !release.value(QStringLiteral("manifestUri")).toString().isEmpty();
if (hasPackages && hasManifest) {
selected = release;
break;
}
}
if (selected.isEmpty()) {
m_needUpdate = false;
if (!releases.isEmpty())
m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString();
qDebug() << "No entitled downloadable update for current client.";
return;
}
m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString();
m_checkResp = selected;
m_checkResp.insert(QStringLiteral("need_update"), true);
m_checkResp.insert(QStringLiteral("latest_version"), selected.value(QStringLiteral("version")).toString());
m_checkResp.insert(QStringLiteral("release_id"), selected.value(QStringLiteral("id")).toString());
m_needUpdate = true;
m_latestVer = selected.value(QStringLiteral("version")).toString();
qDebug() << "Need update:" << m_needUpdate;
qDebug() << "Latest version:" << m_latestVer;
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
}
else
{
m_needUpdate = false;
qDebug() << "Check update api failed";
}
qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString();
});
}
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();
});
Q_UNUSED(appId);
Q_UNUSED(channel);
Q_UNUSED(ver);
Q_UNUSED(verId);
qDebug() << "SimCAE Hub manifest already contains authorized package download URLs.";
}
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir)
{
Q_UNUSED(versionId);
m_error.clear();
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
m_error = "manifest identity is incomplete";
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty()) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"Current version manifest identity is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
.arg(appId, channel, version);
return false;
}
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
QUrlQuery query;
addQueryValue(query, QStringLiteral("productCode"), appId);
addQueryValue(query, QStringLiteral("version"), version);
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
addQueryValue(query, QStringLiteral("channel"), channel);
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
url.setQuery(query);
QJsonObject response;
int statusCode = 0;
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,
m_http.getRequest(url.toString(QUrl::FullyEncoded),
[&](int code, const QJsonObject& resp) {
statusCode = code;
response = resp;
});
if (statusCode != 200) {
m_error = QString("manifest request failed (HTTP %1)").arg(statusCode);
const QString message = serverMessage(response);
m_error = QCoreApplication::translate(
"UpdateLogic",
"Cannot download manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. Product: %2, channel: %3, version: %4.%5")
.arg(QString::number(statusCode), appId, channel, version,
message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
return false;
}
const QJsonObject manifest = response.value("manifest").toObject();
const QString manifestText = response.value("manifest_text").toString();
QJsonObject wrapper = responseDataObject(response);
const QJsonObject manifest = wrapper.value(QStringLiteral("manifest")).toObject();
QString manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
if (manifestText.isEmpty())
manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
if (manifest.isEmpty() || manifestText.isEmpty()) {
m_error = "manifest response is incomplete";
m_error = QCoreApplication::translate(
"UpdateLogic",
"The manifest response for the current local version is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
.arg(appId, channel, version);
return false;
}
if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = "manifest identity does not match current version";
const QString manifestProduct = manifest.value(QStringLiteral("productCode")).toString(
manifest.value(QStringLiteral("app_id")).toString());
if (manifestProduct != appId
|| manifest.value(QStringLiteral("channel")).toString() != channel
|| manifest.value(QStringLiteral("version")).toString() != version) {
m_error = QCoreApplication::translate(
"UpdateLogic",
"The manifest identity does not match the current local version. Stage: cache current version manifest. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifestProduct,
manifest.value(QStringLiteral("channel")).toString(),
manifest.value(QStringLiteral("version")).toString());
return false;
}
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath(".")) {
m_error = "cannot create manifest cache directory";
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"));
wrapper.insert(QStringLiteral("manifest"), manifest);
wrapper.insert(QStringLiteral("manifestText"), manifestText);
wrapper.insert(QStringLiteral("manifest_text"), manifestText);
QSaveFile file(dir.filePath(QStringLiteral("manifest_") + version + QStringLiteral(".json")));
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
m_error = "cannot save signed manifest cache";
m_error = QCoreApplication::translate(
"UpdateLogic",
"Cannot save manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
.arg(file.fileName(), file.errorString());
return false;
}
qDebug() << "Current version manifest cached to" << file.fileName();
return true;
}
bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
{
Q_UNUSED(outputPath);
m_error.clear();
qDebug() << "Git tag export is not part of the current SimCAE Hub admin pages; skipped.";
return true;
}
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
{
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = m_appId;
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
body["result"] = success ? "success" : "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
qDebug() << "\n========== Update Report Result ==========";
qDebug() << resp;
});
Q_UNUSED(deviceId);
Q_UNUSED(fromVer);
Q_UNUSED(toVer);
Q_UNUSED(success);
qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
}
+2
View File
@@ -15,6 +15,7 @@ public:
void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
bool cacheManifest(const QString& appId, const QString& channel, const QString& version,
int versionId, const QString& cacheDir);
bool refreshGitTagsFile(const QString& outputPath);
bool getNeedUpdate() const { return m_needUpdate; }
QString getLatestVersion() const { return m_latestVer; }
@@ -24,6 +25,7 @@ public:
QString errorString() const { return m_error; }
QString getAppId() const { return m_appId; }
QString getProductCode() const { return m_appId; }
QString getChannel() const { return m_channel; }
private:
+145 -259
View File
@@ -1,28 +1,75 @@
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFileInfo>
#include <QMessageBox>
#include <QProcess>
#include <QProgressDialog>
#include <QInputDialog>
#include <QLineEdit>
#include <QTranslator>
#include <QUuid>
#include "UpdateLogic.h"
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include <QFile>
#include <QFileDialog>
#include <QDir>
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
QString productCode()
{
return configValue(QStringLiteral("product_code"),
configValue(QStringLiteral("app_id")));
}
QString ensureDeviceId()
{
ConfigHelper& config = ConfigHelper::instance();
QString deviceId = config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")).trimmed();
if (!deviceId.isEmpty())
return deviceId;
deviceId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")).trimmed();
if (deviceId.isEmpty())
deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces);
config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), deviceId);
config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), deviceId);
return deviceId;
}
QString authFailureMessage(const QJsonObject& response, const QString& fallback)
{
const QString msg = response.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty())
return msg;
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject object = detail.toObject();
const QString detailMsg = object.value(QStringLiteral("msg")).toString();
if (!detailMsg.isEmpty())
return detailMsg;
const QString error = object.value(QStringLiteral("error")).toString();
if (!error.isEmpty())
return error;
}
const QString detailText = detail.toString();
return detailText.isEmpty() ? fallback : detailText;
}
} // namespace
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher");
QApplication::setApplicationName("SimCAE Launcher");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
app.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
@@ -30,7 +77,7 @@ int main(int argc, char* argv[])
return elevatedWriteExitCode;
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher"));
progress.setWindowTitle(QCoreApplication::translate("Launcher", "SimCAE Launcher"));
progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0);
@@ -38,92 +85,11 @@ int main(int argc, char* argv[])
progress.show();
QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) {
const QString text = errorText.toLower();
return text.contains("license")
|| text.contains("authorization")
|| text.contains("expired")
|| text.contains("device limit")
|| text.contains("bound to another license");
};
const auto clearDeviceCredential = [&]() {
ConfigHelper::removeFileWithElevationIfNeeded(QDir(appDir).filePath("config/client_identity.dat"));
config.setValue("Update", "device_id", QString());
};
QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
QString promptReason = reason;
while (true) {
progress.close();
bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed();
const QString prompt = promptReason.trimmed().isEmpty()
? QCoreApplication::translate("Launcher", "Please enter the License for %1:")
.arg(appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName)
: QCoreApplication::translate("Launcher", "%1\n\nPlease enter a new License for %2:")
.arg(promptReason, appName.isEmpty() ? QCoreApplication::translate("Launcher", "the application") : appName);
licenseKey = QInputDialog::getText(
nullptr,
QCoreApplication::translate("Launcher", "Enter License"),
prompt,
QLineEdit::Normal,
QString(),
&accepted
).trimmed();
if (!accepted) {
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Required"),
QCoreApplication::translate("Launcher", "A License provided by the administrator is required for the first launch."));
return QString();
}
if (licenseKey.isEmpty()) {
QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
QCoreApplication::translate("Launcher", "Please paste the License created in the admin page."));
promptReason.clear();
continue;
}
if (!config.setValue("License", "license_key", licenseKey)) {
QMessageBox::critical(nullptr,
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();
}
progress.show();
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying License..."));
const QString appDir = QApplication::applicationDirPath();
progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates..."));
QApplication::processEvents();
return licenseKey;
}
};
while (true) {
if (licenseKey.isEmpty()) {
licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0;
}
DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"),
config.getValue("App", "app_id"),
config.getValue("App", "channel"),
licenseKey)) {
break;
}
const QString error = identity.errorString();
if (!isLicenseError(error)) {
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Device Authentication Failed"),
error);
return -1;
}
clearDeviceCredential();
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current License cannot be used: %1").arg(error));
if (licenseKey.isEmpty()) return 0;
}
UpdateLogic logic;
logic.checkUpdate();
@@ -131,218 +97,138 @@ int main(int argc, char* argv[])
const bool networkOk = logic.isNetworkOk();
const QString latestVer = logic.getLatestVersion();
const QJsonObject response = logic.getCheckResult();
const int targetVersionId = response.value("version_id").toInt();
const QString appId = logic.getAppId();
const QString releaseId = response.value(QStringLiteral("release_id")).toString(
response.value(QStringLiteral("id")).toString());
const QString appId = logic.getProductCode();
const QString channel = logic.getChannel();
const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version");
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close();
const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty()
? QCoreApplication::translate("Launcher", "The device or License authorization is invalid.")
: message;
if (isLicenseError(displayMessage)) {
clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current authorization was rejected by the server: %1").arg(displayMessage));
if (!newLicense.isEmpty()) {
progress.close();
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "License Saved"),
QCoreApplication::translate("Launcher", "Please restart Launcher to complete device authorization and update checking."));
}
return 0;
}
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Authorization Rejected"),
displayMessage);
return -1;
}
const QString launchToken = config.getValue(QStringLiteral("App"), QStringLiteral("launch_token"));
const QString currentVersion = config.getValue(QStringLiteral("App"), QStringLiteral("current_version"));
const QString deviceId = ensureDeviceId();
const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
return ConfigHelper::executableNameForCurrentPlatform(
config.getValue(QStringLiteral("Runtime"), key), fallback);
};
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString mainExecutable = configuredName(QStringLiteral("main_executable"), QStringLiteral("MainApp"));
const QString updaterExecutable = configuredName(QStringLiteral("updater_executable"), QStringLiteral("Updater"));
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
const auto importOfflinePackage = [&]() {
const QString package = QFileDialog::getOpenFileName(nullptr,
QCoreApplication::translate("Launcher", "Select Offline Update Package"),
QString(),
QCoreApplication::translate("Launcher", "Marsco offline update package (*.upd)"));
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
};
const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close();
if (!importOfflinePackage())
QMessageBox::information(nullptr,
QCoreApplication::translate("Launcher", "Offline Update"),
QCoreApplication::translate("Launcher", "No offline update package was selected."));
return 0;
}
QString mainStartupError;
const auto startMainApp = [&]() {
QString ticketPath;
QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath;
QString ticketError;
if (!TicketHelper::createTicket(appId, deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false;
}
const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) QFile::remove(ticketPath);
QStringList{QStringLiteral("--ticket-file=%1").arg(ticketPath)});
if (!started) {
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Launcher",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath);
}
return started;
};
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying local runtime policy..."));
QApplication::processEvents();
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
QCoreApplication::translate("Launcher", "Update Client Rejected"),
authFailureMessage(response,
QCoreApplication::translate("Launcher", "The update client token is missing or invalid. Please download a valid package from the customer portal.")));
return -1;
}
if (policy.isExpired())
{
} else if (!networkOk) {
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "The local version policy has expired. Please connect to the network or contact the administrator."));
return -1;
}
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
{
progress.close();
QMessageBox::critical(nullptr,
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());
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Cannot Start"),
QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Security Check Failed"),
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected. Startup has been blocked."));
return -1;
}
if (state.isSystemTimeRewound())
{
progress.close();
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;
QMessageBox::warning(nullptr,
QCoreApplication::translate("Launcher", "Update Server Unavailable"),
QCoreApplication::translate("Launcher", "Cannot connect to the update server. The installed application will be started without downloading an update."));
progress.show();
}
if (networkOk && needUpdate)
{
progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
const QString dialogTitle = rollbackOperation ? QCoreApplication::translate("Launcher", "Version Rollback")
: (policy.forceUpdate() ? QCoreApplication::translate("Launcher", "Update Required") : QCoreApplication::translate("Launcher", "New Version Available"));
const QString prompt = policy.message().isEmpty()
? (rollbackOperation
? QCoreApplication::translate("Launcher", "The administrator provided version %1 as the rollback target. Downgrade now?").arg(latestVer)
: QCoreApplication::translate("Launcher", "Version %1 is available. Update now?").arg(latestVer))
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
bool accepted = true;
if (policy.forceUpdate()) {
QMessageBox::information(nullptr, dialogTitle, prompt);
} else {
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
}
const QString prompt = QCoreApplication::translate(
"Launcher",
"Version %1 is available. Update now?").arg(latestVer);
const bool accepted = QMessageBox::question(nullptr,
QCoreApplication::translate("Launcher", "New Version Available"),
prompt,
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No) == QMessageBox::Yes;
if (!accepted) {
if (!startMainApp()) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1;
}
return 0;
}
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
const QStringList updaterArgs{
appId,
channel,
latestVer,
QStringLiteral("0"),
QStringLiteral("--release-id=%1").arg(releaseId)
};
if (!QFileInfo::exists(updaterPath))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate(
"Launcher",
"Cannot start the updater because the executable file does not exist.\nExecutable: %1\nCheck updater_executable and install_root in the generated client configuration.")
.arg(updaterPath));
return -1;
}
if (!QProcess::startDetached(updaterPath, updaterArgs))
{
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath));
QCoreApplication::translate(
"Launcher",
"Cannot start the updater process.\nExecutable: %1\nArguments: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the updater can run independently.")
.arg(updaterPath, updaterArgs.join(QStringLiteral(" "))));
return -1;
}
return 0;
}
if (networkOk && !needUpdate && targetVersionId > 0 && latestVer == currentVersion)
{
progress.setLabelText(QCoreApplication::translate("Launcher", "Caching signed version manifest..."));
QApplication::processEvents();
const QString manifestCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("manifest_cache");
if (!logic.cacheManifest(appId, channel, currentVersion, targetVersionId, manifestCacheDir))
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Manifest Cache Failed"),
QCoreApplication::translate("Launcher", "Cannot cache the signed manifest for the current version: %1").arg(logic.errorString()));
return -1;
}
}
if (!networkOk) {
progress.close();
if (QMessageBox::question(nullptr,
QCoreApplication::translate("Launcher", "Server Unavailable"),
QCoreApplication::translate("Launcher", "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())
{
progress.close();
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;
}
progress.setLabelText(networkOk
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
: QCoreApplication::translate("Launcher", "Offline startup. Starting..."));
QApplication::processEvents();
if (!startMainApp())
{
progress.close();
QMessageBox::critical(nullptr,
QCoreApplication::translate("Launcher", "Startup Failed"),
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
mainStartupError.isEmpty()
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
: mainStartupError);
return -1;
}
progress.close();
+2 -1
View File
@@ -5,7 +5,8 @@ add_executable(MainApp
MainWindow.cpp
../Common/ConfigHelper.h
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
)
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
target_include_directories(MainApp
PUBLIC ${CMAKE_SOURCE_DIR}/Common
PRIVATE ${OPENSSL_INC}
+38 -41
View File
@@ -1,69 +1,66 @@
#include "MainWindow.h"
#include <QApplication>
#include <QFont>
#include <QCryptographicHash>
#include <QFile>
#include <QFont>
#include <QLabel>
#include <QVBoxLayout>
#include <QCryptographicHash>
#include "../Common/PolicyHelper.h"
#include "../Common/ConfigHelper.h"
static QString readDllVersion(const QString& dllPath)
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
QString readDllVersion(const QString& dllPath)
{
QFile file(dllPath);
if (!file.exists())
return "missing";
return QStringLiteral("missing");
if (!file.open(QIODevice::ReadOnly))
return "unreadable";
return QStringLiteral("unreadable");
QByteArray data = file.read(1024);
const QByteArray data = file.read(1024);
file.close();
return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8));
}
} // namespace
MainWindow::MainWindow(QWidget* parent)
: QWidget(parent)
{
this->setWindowTitle("Marsco Demo MainApp");
this->setWindowTitle("SimCAE Demo MainApp");
this->resize(600, 400);
QString appVersion = ConfigHelper::instance().getValue("App", "current_version");
if (appVersion.isEmpty())
appVersion = "unknown";
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
PolicyHelper policy(QApplication::applicationDirPath());
QString policyResult;
if (!policy.loadPolicy("config/version_policy.dat"))
{
policyResult = "policy missing";
}
else if (!policy.isValid())
{
policyResult = "policy invalid";
}
else if (!policy.isVersionAllowed(appVersion))
{
policyResult = "version disabled";
}
else if (policy.isExpired())
{
policyResult = "policy expired";
}
else
{
policyResult = "policy ok";
}
const QString appVersion = configValue(QStringLiteral("current_version"), QStringLiteral("unknown"));
const QString product = configValue(QStringLiteral("product_code"),
configValue(QStringLiteral("app_id"), QStringLiteral("unknown")));
const QString channel = configValue(QStringLiteral("channel"), QStringLiteral("stable"));
const QString apiBaseUrl = configValue(QStringLiteral("api_base_url"), QStringLiteral("not configured"));
const QString tokenState = configValue(QStringLiteral("client_token")).isEmpty()
? QStringLiteral("missing")
: QStringLiteral("configured");
const QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
QVBoxLayout* layout = new QVBoxLayout(this);
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
.arg(appVersion)
.arg(dllVersion)
.arg(policyResult));
QLabel* label = new QLabel(QString(
"Software Running Successfully\n"
"Product: %1\n"
"Version: %2\n"
"Channel: %3\n"
"Server: %4\n"
"Update Client Token: %5\n"
"DLL Version: %6")
.arg(product, appVersion, channel, apiBaseUrl, tokenState, dllVersion));
QFont font = label->font();
font.setPointSize(14);
font.setPointSize(13);
label->setFont(font);
label->setAlignment(Qt::AlignCenter);
+48 -66
View File
@@ -1,23 +1,46 @@
#include <QApplication>
#include <QApplication>
#include <QDebug>
#include <QMessageBox>
#include <QDir>
#include <QFileInfo>
#include <QMessageBox>
#include <QSaveFile>
#include <QTranslator>
#include "MainWindow.h"
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include "../Common/TicketHelper.h"
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
QString productCode()
{
return configValue(QStringLiteral("product_code"),
configValue(QStringLiteral("app_id")));
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
} // namespace
int main(int argc, char* argv[])
{
QApplication a(argc, argv);
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
a.installTranslator(&translator);
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
@@ -27,24 +50,26 @@ int main(int argc, char* argv[])
qDebug() << Qt::endl << "entered main app" << Qt::endl;
ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
QString launcherExecutable = config.getValue(QStringLiteral("Runtime"), QStringLiteral("launcher_executable")).trimmed();
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, QStringLiteral("Launcher"));
QString ticketFilePath;
QString healthFilePath;
for (int i = 1; i < argc; ++i)
{
const QString arg(argv[i]);
if (arg.startsWith("--ticket-file="))
ticketFilePath = arg.mid(QString("--ticket-file=").size());
else if (arg.startsWith("--health-file="))
healthFilePath = arg.mid(QString("--health-file=").size());
if (arg.startsWith(QStringLiteral("--ticket-file=")))
ticketFilePath = arg.mid(QStringLiteral("--ticket-file=").size());
else if (arg.startsWith(QStringLiteral("--health-file=")))
healthFilePath = arg.mid(QStringLiteral("--health-file=").size());
}
QString ticketError;
if (ticketFilePath.isEmpty()
|| !TicketHelper::consumeAndVerify(ticketFilePath,
config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
productCode(), config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")),
config.getValue(QStringLiteral("App"), QStringLiteral("current_version")),
config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")),
&ticketError))
{
QMessageBox::critical(nullptr, "Startup Restriction",
@@ -53,65 +78,19 @@ int main(int argc, char* argv[])
return -1;
}
QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
{
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
return -1;
}
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
return -1;
}
if (policy.isExpired())
{
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
return -1;
}
LocalStateHelper state(appDir);
if (!state.loadState())
{
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
return -1;
}
if (state.isPolicySeqRolledBack(policy.policySeq()))
{
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
return -1;
}
if (state.isSystemTimeRewound())
{
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
return -1;
}
if (configFlag(QStringLiteral("verify_installed_on_start"))) {
IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version")))
productCode(),
config.getValue(QStringLiteral("App"), QStringLiteral("channel")),
config.getValue(QStringLiteral("App"), QStringLiteral("current_version"))))
{
QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Application files failed signed Manifest verification:\n%1")
QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1")
.arg(integrity.errorString()));
return -1;
}
MainWindow w;
w.show();
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState())
{
QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString()));
return -1;
}
if (!healthFilePath.isEmpty())
@@ -128,6 +107,9 @@ int main(int argc, char* argv[])
}
}
MainWindow w;
w.show();
qDebug() << "Main program MainApp is running normally";
return a.exec();
}
+265
View File
@@ -0,0 +1,265 @@
# SimCAE Hub 更新客户端
`update-client` 是 SimCAE Hub 的 Qt/C++ 整包更新客户端,包含 `Launcher``Updater``Bootstrap` 和一个示例 `MainApp`。它负责检查整包更新、拉取 Manifest、下载发布包、校验哈希并完成本地安装。
组件级安装、组件级更新和卸载由 Qt IFW 生成的 `maintenancetool.exe` 负责。`update-client` 不替代 MaintenanceTool,也不读取 Qt IFW 的 `Updates.xml`
## 一、当前接入方式
当前 SIMCAE 的标准接入方式是:
| 阶段 | 发生什么 |
| --- | --- |
| SDK 打包 | `update-client` 只打出 `Launcher.exe``Updater.exe``Bootstrap.exe` 和运行库 |
| SIMCAE 打包 | SIMCAE 的 `installer` 规则把 SDK 文件放进核心组件 `com.simcae.app` |
| 本地 IFW package | Hub 更新客户端位于 `package/packages/com.simcae.app/data/view/bin` |
| 上传到 Hub | 服务端校验 IFW 交付包,并自动注入最终客户配置 |
| 客户安装 | 客户从门户下载安装器,安装后得到 `maintenancetool.exe``view/bin/Launcher.exe` |
| 日常启动 | 客户通过 `Launcher.exe` 或安装器创建的快捷方式启动 SimCAE |
最终客户不需要手动填写 `app_config.json`、服务器地址、token、产品编码、平台架构或版本号。
## 二、程序组成
| 程序 | 作用 |
| --- | --- |
| `Launcher` | 客户日常启动入口,读取配置、检查整包更新、启动 `Updater` 或主程序 |
| `Updater` | 拉取 Manifest、下载发布包、校验文件、准备安装事务 |
| `Bootstrap` | 替换运行中文件,并把安装结果交回 `Updater` |
| `MainApp` | 示例主程序,用于验证 launch ticket 和启动前完整性校验 |
| `Common` | 配置、HTTP、票据、路径、Manifest 和完整性校验等公共代码 |
真实接入 SIMCAE 时,`MainApp` 只是示例程序。正式主程序是 SIMCAE 自己的 `SimCAE.exe`
## 三、在线更新链路
1. 客户启动 `Launcher.exe`
2. 客户端读取服务端注入的初始配置。
3. 首次启动时,客户端会把 `app_config.json` 中的运行配置导入本机用户配置。
4. 为减少明文配置暴露,导入成功后客户端可能清空安装目录里的 `app_config.json`
5. `Launcher` 确保本机有 `device_id`
6. `Launcher` 使用 `X-Client-Token` 调用更新检查接口。
7. 如果服务端返回可用发布,`Launcher` 启动 `Updater`
8. `Updater` 使用 `X-Client-Token` 拉取 Manifest。
9. `Updater` 校验 Manifest 摘要,并按配置决定是否要求签名。
10. `Updater` 按 Manifest 下载文件。
11. 每个文件下载完成后校验大小和 SHA-256。
12. 安装前校验 staging 目录。
13. 如需替换运行中文件,`Bootstrap` 接管安装。
14. 安装完成后保存 Manifest 缓存和本地状态。
15. 如果主程序开启启动前完整性校验,下次启动时会按本地 Manifest 缓存校验已安装文件。
更新接口使用部署级 `client_token`,不使用客户邮箱密码。客户账号和授权主要控制门户下载、客户权益和席位,不要求最终客户启动软件时再登录。
## 四、当前 SIMCAE 目录规则
客户安装完成后的关键目录是:
| 路径 | 说明 |
| --- | --- |
| `maintenancetool.exe` | Qt IFW 生成的组件维护工具,位于安装根目录 |
| `components.xml` | Qt IFW 记录的已安装组件状态,位于安装根目录 |
| `network.xml` | Qt IFW 记录的组件仓库地址,位于安装根目录 |
| `view/bin/Launcher.exe` | Hub 更新客户端启动入口 |
| `view/bin/Updater.exe` | Hub 整包更新程序 |
| `view/bin/Bootstrap.exe` | Hub 安装接管程序 |
| `view/bin/SimCAE.exe` | SIMCAE 业务主程序 |
| `view/bin/config/app_config.json` | 服务端注入的初始客户配置 |
当前服务端会识别三种运行目录:
| 运行目录 | 服务端写入的 `install_root` | 说明 |
| --- | --- | --- |
| 软件根目录 | `.` | `Launcher` 和主程序就在软件根目录 |
| `bin` | `..` | `Launcher` 在一层 `bin` 目录中 |
| `view/bin` | `../..` | 当前 SIMCAE 标准结构,`Launcher``view/bin` 中 |
`install_root` 不是让客户手动填写的字段。上传发布包或 Qt IFW 交付包时,服务端会根据 `Launcher``Updater``Bootstrap` 的实际位置自动判断。
## 五、服务端注入的配置
正式客户包中的 `app_config.json` 由服务端生成或替换。开发者打 SDK 时不放最终配置,客户也不手动改配置。
服务端生成配置时,信息来源如下:
| 配置内容 | 来源 |
| --- | --- |
| `product_code``app_id` | 管理后台“产品目录”的产品编码 |
| `app_name` | 管理后台“产品目录”的产品名称 |
| `channel` | 管理后台“软件发布”的发布通道 |
| `current_version` | 管理后台“产品版本”的版本号 |
| `platform``arch``abi` | 管理后台“平台管理”和发布包选择的平台 |
| `api_base_url` | 服务端 `.env``SIMCAE_CLIENT_API_BASE_URL` |
| `client_token` | 服务端 `.env``SIMCAE_CLIENT_TOKEN` |
| `launch_token` | 服务端 `.env``SIMCAE_LAUNCH_TOKEN` |
| `install_root` | 服务端根据运行目录自动判断 |
| `main_executable` | 服务端在运行目录中识别到的业务主程序,SIMCAE 当前为 `SimCAE.exe` |
| `launcher_executable` | 按平台生成,Windows 为 `Launcher.exe` |
| `updater_executable` | 按平台生成,Windows 为 `Updater.exe` |
| `bootstrap_executable` | 按平台生成,Windows 为 `Bootstrap.exe` |
| `require_manifest_signature` | 服务端 Manifest 签名配置 |
| `verify_installed_on_start` | 当前服务端默认写入 `false`,需要强制启动校验时再按发布策略开启 |
如果上传包里已经带了旧的 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`,服务端会按当前发布信息重新处理,不让开发机临时配置直接进入最终客户包。
## 六、本地调试配置
正式发布不要手写最终 `app_config.json`。如果开发者只是在本机调试 `Launcher``Updater`,可以创建未提交的 `config/app_config.local.json`
CMake 只在本地输出目录还没有 `config/app_config.json` 时,才会把 `app_config.local.json` 复制成调试用配置。这个文件只服务本机调试,不代表服务端最终注入结果。
`config/server_config.json``config/server_config.qrc` 用于把兜底 API 地址编译进 EXE。正式客户包优先使用服务端注入的 `api_base_url`,一般不需要让客户看到或修改 `server_config.json`
## 七、启动门禁
如果 SIMCAE 主程序开启 `SimCAE_UseLauncher=ON`,用户直接双击 `SimCAE.exe` 会被拦截,必须通过 `Launcher.exe` 启动。
这套机制依赖 `launch_token`
| 位置 | 要求 |
| --- | --- |
| 服务端 `.env` | 必须配置 `SIMCAE_LAUNCH_TOKEN` |
| SIMCAE 编译期 | 主程序编译时使用同一个 token |
| 客户端配置 | 服务端把同一个 token 写入最终客户配置 |
如果三处 token 不一致,就会出现“直接双击被拦住,但从 `Launcher` 启动也失败”的问题。
## 八、Manifest 和哈希校验
整包更新使用 SimCAE Hub Manifest,不使用 Qt IFW 的 `Updates.xml`
Manifest 负责描述:
| 内容 | 说明 |
| --- | --- |
| 发布版本 | 本次更新属于哪个产品、版本线、版本和通道 |
| 文件清单 | 本次发布包含哪些文件 |
| 下载地址 | 每个文件从哪个受控接口下载 |
| 文件大小 | 客户端下载后必须一致 |
| SHA-256 | 客户端下载后必须一致 |
| 是否必选 | 必选文件缺失会阻止启动,可选组件文件可由 MaintenanceTool 管理 |
服务端返回 Manifest 前会重新检查发布包文件是否存在、大小是否一致、SHA-256 是否一致。客户端下载完成后也会再次校验大小和 SHA-256。
## 九、和 MaintenanceTool 的边界
| 能力 | 使用程序 | 文件格式 |
| --- | --- | --- |
| 整包更新 | `Launcher``Updater``Bootstrap` | SimCAE Hub 发布包和 Manifest |
| 组件安装、更新、移除 | `maintenancetool.exe` | Qt IFW repository 和 `Updates.xml` |
两条线可以共存,但不要混淆:
1. `Updater` 不读取 `Updates.xml`
2. `MaintenanceTool` 不读取 SimCAE Hub Manifest。
3. `Updater` 不拉起 `MaintenanceTool`
4. `MaintenanceTool` 由客户手动打开,或由 Qt IFW 自己的流程使用。
5. 核心组件通常包含 `Launcher``Updater``Bootstrap``SimCAE.exe`
6. 可选组件例如 DAP 插件,可以由 MaintenanceTool 单独安装、更新或移除。
## 十、编译环境
| 依赖 | 要求 |
| --- | --- |
| CMake | 建议 3.20 或更高版本 |
| C++ | C++17 |
| Qt | Qt 5,至少需要 Core、Network、Gui、Widgets |
| OpenSSL | 用于 Manifest RSA-SHA256 验签 |
| 编译器 | Windows 推荐 Visual Studio 2022 x64Linux 推荐 gcc/g++ |
项目提供的 CMake Preset
| Preset | 平台 | 用途 |
| --- | --- | --- |
| `x64-debug` | Windows | Debug 编译 |
| `x64-release` | Windows | Release 编译 |
| `linux-x64-debug` | Linux | Debug 编译 |
| `linux-x64-release` | Linux | Release 编译 |
## 十一、Windows 编译
建议安装 Visual Studio 2022、Qt 5 x64、CMake 和 OpenSSL x64。
如果 Qt 没有加入环境变量,可以在编译前指定:
```powershell
$env:CMAKE_PREFIX_PATH = "C:\Qt\5.15.2\msvc2019_64"
```
当前测试机 OpenSSL 路径是 `C:\Program Files\OpenSSL-Win64`Release 编译命令:
```powershell
cmake --preset x64-release -DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
cmake --build --preset x64-release
```
如果 OpenSSL 安装在其他目录,只改 `SIMCAE_OPENSSL_ROOT` 这一项。
Windows Release 产物通常输出到 `out/bin/Release`
检查核心程序:
```powershell
Test-Path .\out\bin\Release\Launcher.exe
Test-Path .\out\bin\Release\Updater.exe
Test-Path .\out\bin\Release\Bootstrap.exe
```
预期都返回 `True`
## 十二、Linux 编译
Ubuntu 示例:
```bash
sudo apt update
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev qttools5-dev-tools libssl-dev
cmake --preset linux-x64-release
cmake --build --preset linux-x64-release
```
Linux 下通常直接使用系统 OpenSSL;如果需要指定自定义 OpenSSL,也可以通过 CMake 变量配置。
## 十三、打包 SDK
SDK 打包流程见当前目录 [updater打包成SDK.md](updater打包成SDK.md)。SIMCAE 拿到 SDK 后的安装器、交付包和上传流程见 [SIMCAE打包上传.md](SIMCAE打包上传.md)。
常用输出:
| 输出 | 说明 |
| --- | --- |
| `dist\UpdateClientSDK` | 不带 Qt 运行库的 SDK 展开目录 |
| `dist\UpdateClientSDK.zip` | 不带 Qt 运行库的 SDK 压缩包 |
| `dist\UpdateClientSDK-With-QtDll` | 带 Qt 运行库 DLL 的 SDK 展开目录 |
| `dist\UpdateClientSDK-With-QtDll.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
SDK 不包含最终客户配置。`With-QtDll` 表示包里带的是运行所需的 Qt DLL,不是完整 Qt SDK。SDK 的目标是给 SIMCAE 打包流程提供更新客户端程序和运行库。
## 十四、运行数据位置
Windows 运行配置会导入当前用户配置,按安装运行目录计算 installation id。运行数据目录通常位于:
`%LOCALAPPDATA%\SimCAE\HubUpdateClient\installations\<安装目录SHA256>\`
Linux 运行数据目录通常位于:
`$XDG_DATA_HOME/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
未设置 `XDG_DATA_HOME` 时通常是:
`~/.local/share/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
Manifest 缓存保存在运行数据目录下的 `update/manifest_cache`
## 十五、常见问题
| 现象 | 排查方向 |
| --- | --- |
| 客户启动后提示配置不完整 | 检查交付包是否经过 SimCAE Hub 上传注入,不要直接拿本地未注入包给客户 |
| 检查更新没有结果 | 检查后台发布是否已发布、发布包是否可用、产品编码、通道和平台是否一致 |
| 下载返回 401 | 检查客户包里的 `client_token` 是否来自当前服务端 `.env` |
| Manifest 校验失败 | 检查服务端文件是否被手工改过,大小和 SHA-256 是否与数据库一致 |
| 强制签名失败 | 检查 `manifest_public_key.pem` 与服务端私钥是否匹配 |
| 主程序启动失败 | 检查 `main_executable` 和运行目录是否正确 |
| 从 `Launcher` 启动也被拦截 | 检查服务端、SIMCAE 编译期和客户配置中的 `launch_token` 是否一致 |
| MaintenanceTool 看不到组件更新 | 检查 IFW repository 地址、`Updates.xml` 和组件版本是否正确 |
+621
View File
@@ -0,0 +1,621 @@
# SIMCAE 打包上传
本文站在 SIMCAE 开发者和发布人员的角度,说明拿到 Hub 更新客户端 SDK 后,SIMCAE 怎么打安装器、怎么生成 Qt IFW 交付包、怎么上传到 SimCAE Hub。
服务端部署流程见 simcae-hub 项目根目录《服务端部署.md》。普通发布人员只需要拿到已经打好的更新客户端 SDK;如果需要重新生成 SDK,见源代码仓库 `SIMCAE/update-client/updater打包成SDK.md`
本文下面的命令默认在 SIMCAE 项目根目录执行。下面用 SIMCAE 当前放在 simcae-hub 项目里的情况举例:
```powershell
cd .\SIMCAE
```
进入后再使用相对路径,例如 `.\installer``.\update-client``.\out\build\...`。这样不要求开发者的 SIMCAE 一定放在某个固定磁盘目录。
## 一、先理解交付物
客户端交付会涉及三类文件:
| 交付物 | 给谁用 | 作用 |
| --- | --- | --- |
| Hub 更新客户端 SDK | SIMCAE 开发者 | 提供 `Launcher.exe``Updater.exe``Bootstrap.exe` 和必要运行库 |
| Qt IFW 交付包 ZIP | 上传到 SimCAE Hub | 包含 IFW package 和 repository,服务端会校验、注入配置、发布仓库并重新生成客户安装器 |
| 客户安装器 | 最终客户 | 客户从门户下载后双击安装,安装后得到 `maintenancetool.exe` |
正式主线是:开发者只上传一个 Qt IFW 交付包 ZIP,客户只从门户下载客户安装器。客户不需要手动改服务器地址、token 或 `app_config.json`
## 二、准备 Hub 更新客户端 SDK
开发者应拿到 `UpdateClientSDK-With-QtDll.zip`
这个 ZIP 由 `update-client` 仓库的 SDK 打包脚本生成,SDK 维护者按源代码仓库 `SIMCAE/update-client/updater打包成SDK.md` 操作即可。
建议手动解压到 SIMCAE 仓库内的固定相对目录:`.\update-client\dist\UpdateClientSDK-With-QtDll`
这里的 `With-QtDll` 表示包里带的是运行所需的 Qt DLL,不是完整 Qt SDK。
如果公司内部统一把 SDK 放在别的位置,也可以,只要后面 `$UpdateClientSdk` 指向解压后的 SDK 目录即可。
解压后至少应有:
- `bin\Launcher.exe`
- `bin\Updater.exe`
- `bin\Bootstrap.exe`
SDK 包不应该包含最终客户配置,例如 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`。这些最终配置由服务端在上传发布包时生成或注入。
## 三、准备 SIMCAE 已编译产物
默认 SIMCAE 已经在开发机上完成 Release 编译。客户端打包文档不要求每次重新全量编译 SIMCAE,因为 SIMCAE 工程很大,打安装包时通常只需要复用已有 Release 产物。
需要确认:
| 内容 | 说明 |
| --- | --- |
| SIMCAE Release 构建目录 | 已经存在 `SimCAE.exe`、库文件、资源文件 |
| Qt Installer Framework | 已经安装 `binarycreator.exe``repogen.exe` |
| DAP 运行时 | 如果启用 DAP 组件,`DAPrailCalxml` 等运行时已放在打包规则要求的位置 |
| Hub 更新客户端 SDK | 已解压,并能找到 `Launcher.exe``Updater.exe``Bootstrap.exe` |
如果业务主程序启用了“必须从 Launcher 启动”的门禁,SIMCAE 编译时使用的 launch token 必须和服务端 `.env` 里的 `SIMCAE_LAUNCH_TOKEN` 一致。
## 四、设置本次打包版本
SIMCAE 安装器文件名、IFW 组件 `package.xml` 版本、repository 里的 `Updates.xml` 版本都来自 CMake 变量 `SimCAE_Version`。这个版本默认读取 SIMCAE 仓库最近的纯数字 Git tag,例如 `1.1.3`
先把几个容易混淆的“版本标签”分清楚:
| 名称 | 写在哪里 | 谁会读取 | 作用 |
| --- | --- | --- | --- |
| Git tag | SIMCAE 仓库提交,例如 `git tag 1.1.3` | CMake 版本脚本 | 生成 `SimCAE_Version`,再写入安装器文件名和组件元数据 |
| 组件版本 | `packages/<组件ID>/meta/package.xml``<Version>` | `repogen.exe` | 生成 repository 时写入 `Updates.xml` |
| repository 组件版本 | `Updates.xml` 里的 `<PackageUpdate><Name>...``<Version>...` | `maintenancetool.exe` | 客户端判断某个组件是否需要更新 |
| ZIP 文件名 | 例如 `SimCAE-Delivery-1.1.3-windows_x86_64-msvc.zip` | 人和管理后台记录 | 方便识别上传文件,不是 MaintenanceTool 的更新依据 |
所以 `git tag 1.1.3` 打的是 SIMCAE 源码提交标签,不是给 ZIP 文件打标签。它会被 CMake 读取后间接变成组件 `package.xml` 里的版本。真正决定 MaintenanceTool 是否更新的是服务器 repository 的 `Updates.xml`,而 `Updates.xml` 又来自组件自己的 `package.xml`
正式发布时推荐在 SIMCAE 仓库给本次发布提交打纯数字 tag,再打包:
```powershell
git tag 1.1.3
```
如果只是本机演示,不想改 SIMCAE 仓库 tag,可以临时指定本次打包版本。下面命令会创建一个本地临时脚本,让 CMake 本次配置时读到 `1.1.3`
```powershell
$Version = "1.1.3"
$GitVersionShim = "..\.tmp\git-version-$Version.cmd"
New-Item -ItemType Directory -Force (Split-Path $GitVersionShim) | Out-Null
@"
@echo off
if /I "%1"=="describe" (
echo $Version
exit /b 0
)
git %*
"@ | Set-Content -LiteralPath $GitVersionShim -Encoding ASCII
```
后续所有命令都复用这个 `$Version`。不要只改 ZIP 文件名,否则会出现文件名是 `1.1.3`,但组件 `package.xml``Updates.xml` 里版本还是 `0.10.1` 的错包;这种包上传后,MaintenanceTool 仍然会按 `0.10.1` 判断。
## 五、刷新现有 CMake 打包配置
下面命令只刷新已有构建目录的 CMake 配置,用来告诉打包目标 Qt IFW 在哪里,以及 Hub 更新客户端的本地编译产物和 SDK 兜底在哪里,不是全量重新编译 SIMCAE。
先确认当前 PowerShell 已经在 SIMCAE 项目根目录。下面给出一个常见 Qt IFW 安装路径示例;如果你的 Qt IFW 装在别的位置,只改 `$QtIfwRoot` 这一行。
```powershell
$Build = ".\out\build\SimCAE-release-vs2022-qt515-ifw"
$QtIfwRoot = "C:\Qt\Tools\QtInstallerFramework\4.11"
$HubUpdateClientRuntime = ".\update-client\out\bin\Release"
$UpdateClientSdk = ".\update-client\dist\UpdateClientSDK-With-QtDll"
```
先检查 SIMCAE 工程里的 Hub 更新客户端本地编译产物:
```powershell
Test-Path "$QtIfwRoot\bin\binarycreator.exe"
Test-Path "$QtIfwRoot\bin\repogen.exe"
Test-Path "$HubUpdateClientRuntime\Launcher.exe"
Test-Path "$HubUpdateClientRuntime\Updater.exe"
Test-Path "$HubUpdateClientRuntime\Bootstrap.exe"
```
如果上面三个 EXE 都存在,打 SIMCAE 安装包时会优先使用它们,不会再从 SDK 目录重复拿一份。
如果本地编译产物不存在,再检查 SDK 兜底目录:
```powershell
Test-Path "$UpdateClientSdk\bin\Launcher.exe"
Test-Path "$UpdateClientSdk\bin\Updater.exe"
Test-Path "$UpdateClientSdk\bin\Bootstrap.exe"
```
如果本次要让 `SimCAE.exe` 只能从 `Launcher.exe` 启动,先准备 SIMCAE 编译期使用的本地打包配置。这里的 `launch_token` 必须和服务端 `.env` 里的 `SIMCAE_LAUNCH_TOKEN` 完全一致。
```powershell
$LauncherProductConfig = "..\.tmp\simcae-launcher-product-config.json"
@'
{
"app_id": "simcae",
"product_code": "simcae",
"app_name": "SimCAE",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "SIMCAE_LOCAL_PACKAGING_LICENSE_2026"
}
'@ | Set-Content -LiteralPath $LauncherProductConfig -Encoding UTF8
```
刷新配置:
```powershell
cmake -S . -B $Build `
"-DSimCAE_QtIfwRoot=$QtIfwRoot" `
"-DSimCAE_PackageHubUpdateClient=ON" `
"-DSimCAE_HubUpdateClientRuntimeDir=$HubUpdateClientRuntime" `
"-DSimCAE_HubUpdateClientSdkDir=$UpdateClientSdk" `
"-DSimCAE_UseLauncher=ON" `
"-DSimCAE_LauncherProductConfigFile=$LauncherProductConfig" `
"-DGIT_EXECUTABLE=$GitVersionShim"
```
`SimCAE_PackageHubUpdateClient=ON` 只负责把 `Launcher.exe``Updater.exe``Bootstrap.exe` 放进 SIMCAE 安装包。正式客户配置文件,例如 `config/app_config.json``config/manifest_public_key.pem`,仍然由服务端在上传发布包时生成或注入,不从开发机本地目录带进最终客户包。
如果只想把 Hub 更新客户端打进安装包,但暂时不限制用户直接双击 `SimCAE.exe`,则把上面命令中的 `SimCAE_UseLauncher` 改为 `OFF`,并去掉 `SimCAE_LauncherProductConfigFile` 这一项。
## 六、生成客户安装器和 IFW package
执行打包目标:
```powershell
cmake --build $Build --config Release --target package_installer
```
这个目标会读取 `SIMCAE\installer` 下的配置和组件规则,整理 IFW package staging,并生成安装器。组件有哪些、每个组件包含哪些文件、组件是否必选、依赖哪些组件,应该由 SIMCAE 开发者在打包配置和 `package.xml.in` 里提前定义好;SimCAE Hub 不会自动猜测业务应该拆成哪些组件。
当前示例里已有两个 IFW 组件:
| 组件目录 | 组件含义 | 元数据来源 |
| --- | --- | --- |
| `packages/com.simcae.app` | 核心程序、Launcher、Updater、Bootstrap、核心库和通用资源 | `installer/packages/meta/package.xml.in` |
| `packages/com.simcae.dap` | DAP 求解器插件及运行资源 | `installer/packages/com.simcae.dap/meta/package.xml.in` |
Qt IFW 的组件 ID 来自 `packages/<组件ID>` 目录名,例如 `com.simcae.dap`。组件显示名称、版本、是否强制安装、依赖关系等来自该组件的 `meta/package.xml`,例如 `<DisplayName>``<Version>``<ForcedInstallation>``<Dependencies>`
常见输出:
| 输出 | 说明 |
| --- | --- |
| `$Build\package` | Qt IFW package staging 目录 |
| `$Build\SimCAE-<版本>-Windows-installer.exe` | 本地生成的客户安装器 |
确认核心组件里已经带上 Hub 更新客户端:
```powershell
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\Launcher.exe"
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\Updater.exe"
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\Bootstrap.exe"
Test-Path "$Build\package\packages\com.simcae.app\data\view\bin\SimCAE.exe"
```
预期都返回 `True`
再确认 package 里的组件版本就是本次 `$Version`
```powershell
$AppPackageXml = "$Build\package\packages\com.simcae.app\meta\package.xml"
$DapPackageXml = "$Build\package\packages\com.simcae.dap\meta\package.xml"
$AppVersion = ([xml](Get-Content -LiteralPath $AppPackageXml -Encoding UTF8 -Raw)).Package.Version
$DapVersion = ([xml](Get-Content -LiteralPath $DapPackageXml -Encoding UTF8 -Raw)).Package.Version
if ($AppVersion -ne $Version -or $DapVersion -ne $Version) {
throw "组件版本不一致:app=$AppVersion dap=$DapVersion expected=$Version"
}
Test-Path "$Build\SimCAE-$Version-Windows-installer.exe"
```
最后一行预期返回 `True`。如果这里不是 `True`,不要继续生成 repository。
## 七、生成 IFW repository
MaintenanceTool 读取的是 Qt IFW repository,不是客户安装器。
生成前先确认 `$Build\package` 已经存在,并且里面至少有 `config``packages`
```powershell
Test-Path "$Build\package\config\config.xml"
Test-Path "$Build\package\packages"
```
预期都返回 `True`。然后生成完整 repository
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\build-ifw-repository.ps1" `
-PackageDir "$Build\package" `
-OutputDir "$Build\ifw-repository" `
-ZipFile "$Build\SimCAE-IFW-Repository-$Version-windows_x86_64-msvc.zip"
```
这条命令不是“给压缩包打版本标签”。它只是调用 Qt IFW 的 `repogen.exe`,从 `$Build\package\packages` 读取已经准备好的组件目录和 `meta/package.xml`,生成 `Updates.xml` 和组件 `.7z` 包,最后把 repository 目录压成 ZIP。ZIP 文件名里的 `$Version` 只是为了让发布人员识别文件。
生成后检查:
```powershell
Test-Path "$Build\ifw-repository\Updates.xml"
Select-String -LiteralPath "$Build\ifw-repository\Updates.xml" -Pattern "com.simcae.app|com.simcae.dap|<Version>"
```
生成的 repository 根目录必须包含 `Updates.xml`。这个 ZIP 一般不直接给客户,它是给 SimCAE Hub 后端托管,供 `maintenancetool.exe` 后续检查组件更新。
确认 repository 里的组件版本也是本次 `$Version`
```powershell
$UpdatesXml = "$Build\ifw-repository\Updates.xml"
$UpdatesContent = Get-Content -LiteralPath $UpdatesXml -Encoding UTF8 -Raw
if ($UpdatesContent -notmatch "<Version>$([regex]::Escape($Version))</Version>") {
throw "repository Updates.xml 中没有本次版本 $Version"
}
```
## 八、组装 Qt IFW 交付包 ZIP
推荐上传给 SimCAE Hub 的是交付包 ZIP,它把 package 和 repository 放在一起。客户安装器由服务端基于注入配置后的 package 重新生成。
目录结构建议:
- `package/config/`
- `package/packages/com.simcae.app/`
- `package/packages/com.simcae.dap/`
- `repository/Updates.xml`
- `repository/com.simcae.app/`
- `repository/com.simcae.dap/`
不要依赖交付包里的 `installer/SimCAE-<版本>-Windows-installer.exe` 作为最终客户安装器。本地生成的安装器没有服务端注入的 `app_config.json`,客户直接安装后 `Launcher.exe` 会缺少服务器地址、token 和主程序配置。服务端必须配置 `SIMCAE_IFW_BINARYCREATOR_PATH`Linux 服务端生成 Windows 安装器时还必须配置 `SIMCAE_IFW_INSTALLERBASE_WINDOWS_PATH` 指向 Windows 版 `installerbase.exe`,上传后由服务端重新生成客户门户可下载的安装器。
示例命令:
```powershell
$PlatformKey = "windows_x86_64-msvc"
$Bundle = "$Build\SimCAE-Delivery-$Version-$PlatformKey"
if (-not (Test-Path "$Build\ifw-repository\Updates.xml")) {
throw "缺少 repository/Updates.xml,请先生成 IFW repository"
}
Remove-Item -LiteralPath $Bundle -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force "$Bundle" | Out-Null
Copy-Item "$Build\package" "$Bundle\package" -Recurse -Force
Copy-Item "$Build\ifw-repository" "$Bundle\repository" -Recurse -Force
Compress-Archive -Path "$Bundle\*" -DestinationPath "$Build\SimCAE-Delivery-$Version-$PlatformKey.zip" -Force
```
`release.json` 不需要开发者手写。产品编码、产品名称、版本号、通道、平台、架构和 ABI 来自管理后台表单;运行目录和主程序名由服务端从核心组件中自动识别。
## 九、上传到管理后台
在浏览器打开管理后台,例如 `http://192.168.1.158:1798/login`
按左侧菜单顺序准备基础数据。第一次发布某个产品时要完整走一遍;后续同产品、同通道、同平台发布新版本时,只需要确认这些数据仍然存在:
1. 产品目录:确认产品编码,例如 `simcae`
2. 版本线:确认通道或版本线,例如 `stable`
3. 组件管理:确认核心组件和可选组件,例如 `com.simcae.app``com.simcae.dap`;如果用于 MaintenanceTool 更新,后台组件编码要和 IFW package 的 `packages/<组件ID>` 目录名一致。
4. 平台管理:确认 `windows``x86_64``msvc`
5. 产品版本:创建本次版本,例如 `1.1.3`
6. 软件发布:创建本次发布,关联产品版本和版本线。
7. 发布包:新增或编辑发布包。
上传完整 Qt IFW 交付包时,在“发布包”页面直接点击右上角新增发布包,不需要先点击某个软件卡片。这个入口只用于新建整包更新包和 Qt IFW 交付包。
发布包页面选择:
| 字段 | 建议 |
| --- | --- |
| 包类型 | Qt IFW 交付包 |
| 文件 | `SimCAE-Delivery-<版本>-windows_x86_64-msvc.zip` |
| 平台 | `windows / x86_64 / msvc` |
| 状态 | 上传校验通过后变为可用 |
操作顺序:
1. 打开“发布包”页面。
2. 直接点击右上角“新建发布包”。
3. 包类型选择“Qt IFW 交付包”。此时普通新建入口只应看到“整包更新包”和“Qt IFW 交付包”。
4. 文件名填写本次交付包文件名,例如 `SimCAE-Delivery-1.1.3-windows_x86_64-msvc.zip`
5. 选择产品、版本线、产品版本、发布和平台。
6. 保存发布包记录。
7. 点击该记录的“上传”。
8. 选择本地生成的交付包 ZIP。
9. 等待上传完成,状态应变为“可用”。
上传成功后,服务端会:
- 校验 ZIP 安全路径和 IFW 结构。
- 校验 `package/``repository/Updates.xml`
- 读取组件清单和组件版本。
- 自动注入 `config/app_config.json`
- 自动写入必要的公钥配置。
- 发布 IFW repository。
- 生成或登记客户门户首次下载的安装器。
上传后建议立刻确认:
```powershell
$Base = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc"
(Invoke-WebRequest "$Base/Updates.xml" -UseBasicParsing).Content
```
预期能看到本次版本号、组件 ID 和组件名称。如果这里还是旧版本,先确认发布包状态是否为“可用”,再确认上传时选择的产品、通道和平台是否一致。
## 十、客户下载和安装
客户登录客户门户后,在下载中心下载客户安装器。客户下载到的是 `.exe` 安装器,不是 IFW repository ZIP,也不是开发者上传的交付包 ZIP。
客户安装后,安装目录中应包含:
- `maintenancetool.exe`
- `components.xml`
- `network.xml`
- `view\bin\SimCAE.exe`
- `view\bin\Launcher.exe`
- `view\bin\Updater.exe`
- `view\bin\Bootstrap.exe`
- `view\bin\config\app_config.json`
客户日常启动软件应使用 `Launcher.exe` 或安装器创建的快捷方式。组件更新、添加、移除由 `maintenancetool.exe` 负责。
首次安装建议按这个顺序检查:
1. 打开客户门户。
2. 登录有授权的客户账号。
3. 进入下载中心。
4. 找到对应产品和版本。
5. 点击下载,得到 `SimCAE-<版本>-Windows-installer.exe`
6. 双击安装器,按页面提示完成安装。
7. 安装后进入安装目录,确认 `maintenancetool.exe``components.xml``view\bin\Launcher.exe` 都存在。
8. 双击 `Launcher.exe`,预期能启动 SimCAE。
9. 双击 `maintenancetool.exe`,预期能看到“添加或移除组件”“更新组件”“移除所有组件”。
## 十一、组件更新
这里的“组件”指 `maintenancetool.exe` 里能看到的 Qt IFW 组件,例如 `com.simcae.app``com.simcae.dap`。组件更新就是“只发布某些组件的新版本,或新增一个组件”,让客户后续通过 MaintenanceTool 更新;它不是客户首次安装用的安装器,也不是 Launcher / Updater 用的整包更新 ZIP。
适合使用组件更新的情况:
| 场景 | 应该怎么做 |
| --- | --- |
| 只更新 DAP 插件 | 做一个只包含 `com.simcae.dap` 的组件更新包 |
| 新增示例、模板、插件等可选功能 | 做一个包含新组件的组件更新包 |
| 一次更新几个互相依赖的组件 | 做一个多组件更新包,把这些组件一起放进去 |
| 更新核心程序、Launcher、Updater、Bootstrap 或 `app_config.json` | 更推荐重新发完整 Qt IFW 交付包 |
| 第一次发布某个产品、通道、平台 | 先发完整 Qt IFW 交付包,后面才能发组件更新 |
组件更新包不是单独飘在系统外面的文件。它上传时必须挂到某个产品、某个产品版本、某次发布、某个平台下面。服务端会把它合并到这个产品对应通道和平台的 current repository 中。
### 11.1 组件版本怎么定
组件版本以组件自己的 `package.xml` 为准。比如 DAP 组件的版本写在这里:
- `$Build\package\packages\com.simcae.dap\meta\package.xml`
- XML 节点是 `<Version>1.1.4</Version>`
如果说“组件标签”,这里真正参与更新判断的是组件 ID 和组件版本:组件 ID 来自目录名 `packages/com.simcae.dap`,组件版本来自 `meta/package.xml` 里的 `<Version>`。开发者在维护 IFW package 时就应该把组件拆分、显示名、版本、必选状态和依赖关系写清楚。`repogen.exe` 生成 repository 时,会把这些信息写进 `Updates.xml`。MaintenanceTool 也是根据 `Updates.xml` 里的组件版本判断是否可更新。
当前 SIMCAE 全量打包默认会让所有组件跟随同一个 `$Version`,这个 `$Version` 来自 SIMCAE 仓库的纯数字 Git tag,或者前面文档里的 `$GitVersionShim` 临时版本脚本。例如 `$Version = "1.1.3"` 时,`com.simcae.app``com.simcae.dap` 默认都会变成 `1.1.3`
如果只更新 DAP,不更新核心组件,规则是:
1. 服务器当前 `com.simcae.app``1.1.3``com.simcae.dap``1.1.3`
2. 本次只把 `com.simcae.dap``package.xml` 改成 `1.1.4`
3. 不改 `com.simcae.app``package.xml`,它仍然保持 `1.1.3`
4. 生成只包含 `com.simcae.dap` 的组件更新包。
5. 上传后,服务器 current repository 里应变成 `com.simcae.app=1.1.3``com.simcae.dap=1.1.4`
正式流程里,建议 SIMCAE 打包侧给每个组件提供独立版本参数。当前如果只是本地演示,可以在 `$Build\package\packages\<组件ID>\meta\package.xml` 里调整目标组件的 `<Version>`,然后再生成组件更新包。不要改不更新的组件版本,也不要只改 ZIP 文件名。ZIP 名字里写了 `1.1.4`,但 `package.xml` 仍是 `1.1.3` 时,生成出来的 `Updates.xml` 也会是 `1.1.3`
### 11.2 先确认服务器已有当前仓库
`simcae / stable / windows_x86_64-msvc` 为例:
```powershell
$Base = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc"
(Invoke-WebRequest "$Base/Updates.xml" -UseBasicParsing).Content
```
预期能看到当前仓库的组件,例如:
- `<Name>com.simcae.app</Name>`
- `<Version>1.1.3</Version>`
- `<Name>com.simcae.dap</Name>`
- `<Version>1.1.3</Version>`
如果这里访问失败,先不要上传组件更新包,说明服务器还没有这个产品、通道、平台的 current repository。
### 11.3 准备本地组件产物
开发者先按 SIMCAE 自己的规则把组件文件准备到 IFW package staging 里。组件边界应该在开发和打包配置阶段就已经分好;后面的 repository 生成命令只是读取这些组件,不会自动分析文件并替开发者拆组件。当前打包目标会把组件整理到:
- `$Build\package\packages\com.simcae.app`
- `$Build\package\packages\com.simcae.dap`
如果只更新 DAP 插件,先确认 DAP 组件目录存在:
```powershell
Test-Path "$Build\package\packages\com.simcae.dap\meta\package.xml"
Test-Path "$Build\package\packages\com.simcae.dap\data"
```
预期都返回 `True`。同时要确认 `package.xml` 里的版本已经升高:
```powershell
Select-String -LiteralPath "$Build\package\packages\com.simcae.dap\meta\package.xml" -Pattern "<Version>"
```
例如服务器当前 DAP 是 `1.1.3`,本次 DAP 组件更新包应改成 `1.1.4` 或更高。
### 11.4 生成组件更新 ZIP
使用 `-Include` 只把要更新的组件打进 repository。`-Include "com.simcae.dap"` 里的值是组件 ID,也就是 `packages/com.simcae.dap` 这个目录名;它不是 ZIP 标签,也不是版本号。下面以只更新 DAP 为例:
```powershell
$ComponentVersion = "1.1.4"
$PlatformKey = "windows_x86_64-msvc"
$ComponentUpdateRepository = "$Build\ifw-component-update-com.simcae.dap-$ComponentVersion"
$ComponentUpdateZip = "$Build\SimCAE-IFW-ComponentUpdate-com.simcae.dap-$ComponentVersion-$PlatformKey.zip"
Remove-Item -LiteralPath $ComponentUpdateRepository -Recurse -Force -ErrorAction SilentlyContinue
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\build-ifw-repository.ps1" `
-PackageDir "$Build\package" `
-OutputDir $ComponentUpdateRepository `
-ZipFile $ComponentUpdateZip `
-Include "com.simcae.dap"
```
这条命令不会修改 `com.simcae.dap``<Version>`。它只是根据 `-Include` 选择已有组件,把该组件当前 `package.xml` 中写好的版本、显示名和依赖交给 `repogen.exe`,再生成本次组件更新 repository ZIP。
如果一次更新多个组件:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\build-ifw-repository.ps1" `
-PackageDir "$Build\package" `
-OutputDir "$Build\ifw-component-update-multi-$ComponentVersion" `
-ZipFile "$Build\SimCAE-IFW-ComponentUpdate-multi-$ComponentVersion-$PlatformKey.zip" `
-Include "com.simcae.app","com.simcae.dap"
```
生成后检查 ZIP 对应的展开目录:
```powershell
Test-Path "$ComponentUpdateRepository\Updates.xml"
Get-ChildItem -LiteralPath $ComponentUpdateRepository
Select-String -LiteralPath "$ComponentUpdateRepository\Updates.xml" -Pattern "com.simcae.dap|<Version>|<Dependencies>"
```
单 DAP 更新包的典型结构应类似:
- `Updates.xml`
- `com.simcae.dap/1.1.4meta.7z`
- `com.simcae.dap/1.1.4view.7z`
- `com.simcae.dap/1.1.4view.7z.sha1`
如果 ZIP 解开后外面多套了一层目录,也可以上传;服务端会识别常见外层目录。但推荐让 ZIP 根部直接就是 `Updates.xml` 和组件目录,最不容易出错。
### 11.5 上传组件更新
管理后台操作:
1. 打开“产品版本”,创建本次发布批次版本,例如 `1.1.4`
2. 打开“软件发布”,创建本次发布,通道仍选择 `stable`
3. 打开“发布包”,先点击要更新的软件卡片,进入该软件的发布包视图。
4. 点击“新建组件更新包”。进入某个软件后,包类型固定为“组件更新”,产品固定为当前软件。
5. 发布选择刚创建的 `1.1.4` 发布。
6. 平台选择和 current repository 完全一致的 `windows / x86_64 / msvc`
7. 文件名填写 `SimCAE-IFW-ComponentUpdate-com.simcae.dap-1.1.4-windows_x86_64-msvc.zip`
8. 保存后点击“上传”。
9. 选择上一步生成的 `$ComponentUpdateZip`
10. 上传成功后,发布包状态应变为“可用”。
上传成功后,服务端会把更新组件合并进 current repository,未变化组件保持不变。
### 11.6 上传后确认合并结果
重新读取服务器仓库:
```powershell
$Base = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc"
(Invoke-WebRequest "$Base/Updates.xml" -UseBasicParsing).Content
```
预期:
1. 更新过的组件版本变成新版本,例如 `com.simcae.dap``1.1.4`
2. 未更新的组件仍然存在,例如 `com.simcae.app` 还在。
3. 新增组件能出现在 `Updates.xml` 中。
4. 旧版本仓库仍保存在服务端 `releases/<版本>` 目录中,current 指向最新合并结果。
### 11.7 常见失败提示
| 提示含义 | 原因 | 处理 |
| --- | --- | --- |
| 当前产品、通道和平台下还没有可合并的 IFW 当前仓库 | 还没上传过完整交付包 | 先上传完整 Qt IFW 交付包 |
| 组件版本不能倒退或重复 | 上传组件版本小于或等于服务器 current 版本 | 升高组件 `package.xml` 里的版本后重新生成 |
| 组件依赖不存在 | 新组件依赖的组件不在 current 仓库,也不在本次包里 | 先发布依赖组件,或把依赖组件一起打进本次更新包 |
| 缺少组件目录 | `Updates.xml` 声明了组件,但 ZIP 里没有对应目录 | 重新用 `build-ifw-repository.ps1` 生成 |
| 包含未在 `Updates.xml` 声明的组件目录 | ZIP 里多了未声明目录 | 删除多余目录后重新压包 |
| 未包含 `Updates.xml` | 上传的不是 repository ZIP,或服务端没有配置 `SIMCAE_IFW_REPOGEN_PATH` 来从 packages 自动生成 | 上传 repository 形态 ZIP |
更新包失败时,服务端不会破坏原 current repository。
## 十二、换源
服务器地址变化时有两种处理方式:
| 方式 | 适用场景 |
| --- | --- |
| 临时换源命令 | 单台客户机器临时切到新仓库 |
| RepositoryUpdate 批量换源 | 已安装客户软件批量迁移仓库地址 |
换源只影响 `maintenancetool.exe` 访问 IFW repository。Launcher / Updater 的 API 地址来自服务端注入的 `app_config.json`,需要通过新发布包或重新安装包更新。
### 12.1 临时换源
临时换源适合开发、测试、临时排查。它不会永久改安装包里的默认源。
在 SIMCAE 项目根目录执行,假设客户软件安装在 `.tmp\maintenance-installed\SimCAE`
```powershell
$Install = ".\.tmp\maintenance-installed\SimCAE"
$Repo = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/"
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\switch-maintenance-repository.ps1" `
-MaintenanceToolPath "$Install\maintenancetool.exe" `
-RepositoryUrl $Repo `
-Mode Temp `
-Command check-updates `
-ClearCache
```
注意 `$Repo` 必须是仓库根地址,不能写到 `Updates.xml`
- 正确:`http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/`
- 错误:`http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/Updates.xml`
### 12.2 批量换源
批量换源适合服务器域名或 IP 变更。做法是在下一次 repository 的 `Updates.xml` 里加入 `RepositoryUpdate`,让 MaintenanceTool 更新组件时顺便替换本机源地址。
示例:把旧源 `http://192.168.1.158:18000/...` 替换为新源 `https://download.simcae.example.com/...`
```powershell
$UpdatesXml = "$Build\ifw-repository\Updates.xml"
$OldRepo = "http://192.168.1.158:18000/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/"
$NewRepo = "https://download.simcae.example.com/api/v1/client/ifw/repositories/simcae/stable/windows_x86_64-msvc/"
powershell -NoProfile -ExecutionPolicy Bypass -File ".\installer\scripts\write-repository-update.ps1" `
-UpdatesXml $UpdatesXml `
-Action replace `
-OldUrl $OldRepo `
-NewUrl $NewRepo `
-DisplayName "SimCAE stable component repository" `
-ClearExisting
```
写入后重新压 repository 或重新组装 Qt IFW 交付包,再上传到 SimCAE Hub。客户下一次通过 MaintenanceTool 检查或更新组件后,会把仓库地址换成新地址。
## 十三、常见问题
| 现象 | 原因和处理 |
| --- | --- |
| 上传提示缺少 `Updates.xml` | 选择的不是 repository 或交付包结构不对 |
| MaintenanceTool 看不到更新 | 服务器仓库版本没有高于本机 `components.xml` 里的版本 |
| 客户下载不到安装器 | 发布包不是 Qt IFW 交付包,或客户安装器生成/登记失败 |
| Launcher 启动主程序失败 | `SIMCAE_LAUNCH_TOKEN` 和业务主程序编译时 token 不一致 |
| 直接双击 `SimCAE.exe` 被拦截 | 这是启用 Launcher 启动门禁后的预期行为 |
| 可选组件删除后 Updater 报缺文件 | Manifest 中可选组件文件没有标为可选,或组件边界划分不对 |
+9 -6
View File
@@ -1,8 +1,10 @@
# 强制所有编译、设计时生成均使用x64,禁止Win32
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁Win32")
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
# 关闭VS自动Win32设计时预生成
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
if(WIN32 AND MSVC)
# 强制所有编译、设计时生成均使用x64,禁Win32
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
# 关闭VS自动Win32设计时预生成
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
endif()
project(Updater)
set(SRC
@@ -12,7 +14,8 @@ set(SRC
UpdateTransaction.h
UpdateTransaction.cpp
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
)
${CMAKE_SOURCE_DIR}/config/server_config.qrc
)
add_executable(Updater ${SRC})
if(WIN32)
+7
View File
@@ -37,6 +37,8 @@ bool UpdateTransaction::copyOverwrite(const QString& source, const QString& dest
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
{
// upgrade_state.json 是升级事务的“黑匣子”。
// 如果替换文件时断电或崩溃,Bootstrap/Updater 会根据这里的状态继续提交或回滚。
m_state["transaction_id"] = m_transactionId;
m_state["from_version"] = m_fromVersion;
m_state["to_version"] = m_toVersion;
@@ -148,6 +150,8 @@ bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
bool UpdateTransaction::backupCurrentFiles()
{
// 替换前先备份所有将被修改或删除的文件。
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
if (!writeState("waiting_mainapp_exit")) return false;
QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths);
@@ -163,6 +167,8 @@ bool UpdateTransaction::backupCurrentFiles()
bool UpdateTransaction::installStagedFiles(QString* failedPath)
{
// staging 目录里只放已经下载并校验过 hash 的新文件。
// 真正覆盖安装目录时如果任意一个文件失败,就进入 rollback_required。
if (!writeState("replacing")) return false;
for (const QString& path : m_changedPaths) {
const QString source = QDir(m_stagingDir).filePath(path);
@@ -226,6 +232,7 @@ QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePat
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion, QString* errorMessage)
{
// 启动时恢复未完成事务:如果上次升级停在替换/验证/回滚中间,优先恢复到可启动状态。
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
.filePath("upgrade_state.json");
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
+374 -145
View File
@@ -15,8 +15,11 @@
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
#include <QUrl>
#include <QUrlQuery>
#include <algorithm>
#include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#ifdef HAVE_OPENSSL
#include <openssl/pem.h>
@@ -25,33 +28,142 @@
#include <openssl/err.h>
#endif
namespace {
QString trimBaseUrl(QString value)
{
value = value.trimmed();
while (value.endsWith(QLatin1Char('/')))
value.chop(1);
return value;
}
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
{
const QString trimmed = value.trimmed();
if (!trimmed.isEmpty())
query.addQueryItem(key, trimmed);
}
QString serverDetailMessage(const QJsonObject& response)
{
const QString msg = response.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty())
return msg;
const QJsonValue detail = response.value(QStringLiteral("detail"));
if (detail.isObject()) {
const QJsonObject obj = detail.toObject();
const QString msg = obj.value(QStringLiteral("msg")).toString();
if (!msg.isEmpty()) return msg;
const QString error = obj.value(QStringLiteral("error")).toString();
if (!error.isEmpty()) return error;
}
return detail.toString();
}
qint64 manifestFileSize(const QJsonObject& file)
{
if (file.contains(QStringLiteral("sizeBytes")))
return file.value(QStringLiteral("sizeBytes")).toVariant().toLongLong();
if (file.contains(QStringLiteral("size")))
return file.value(QStringLiteral("size")).toVariant().toLongLong();
return -1;
}
QString absoluteDownloadUrl(const QString& baseUrl, const QString& downloadUrl)
{
const QString trimmed = downloadUrl.trimmed();
if (trimmed.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)
|| trimmed.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive))
return trimmed;
if (trimmed.startsWith(QLatin1Char('/')))
return trimBaseUrl(baseUrl) + trimmed;
return trimBaseUrl(baseUrl) + QLatin1Char('/') + trimmed;
}
}
UpdaterLogic::UpdaterLogic(QObject* parent)
: QObject(parent)
{
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
m_serverAddr = trimBaseUrl(ConfigHelper::instance().getValue("Server", "api_base_url"));
}
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer,
int versionId, const QString& releaseId)
{
QString url = m_serverAddr + "/api/v1/update/manifest";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = targetVer;
body["version_id"] = versionId;
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
m_error.clear();
Q_UNUSED(versionId);
m_manifestSha256.clear();
m_manifestSignature.clear();
m_manifestSignatureAlg.clear();
m_manifestKeyId.clear();
m_manifestSigned = false;
m_fileItems.clear();
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
QUrlQuery query;
addQueryValue(query, QStringLiteral("releaseId"), releaseId);
addQueryValue(query, QStringLiteral("productCode"), appId);
addQueryValue(query, QStringLiteral("version"), targetVer);
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
addQueryValue(query, QStringLiteral("channel"), channel);
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
url.setQuery(query);
m_http.getRequest(url.toString(QUrl::FullyEncoded),
[this, appId, channel, targetVer, releaseId](int code, const QJsonObject& resp)
{
qDebug() << "Manifest API returned code:" << code;
m_manifest = QJsonObject();
m_manifestText.clear();
m_manifestSha256.clear();
m_manifestSignature.clear();
m_manifestSignatureAlg.clear();
m_manifestKeyId.clear();
m_manifestSigned = false;
if (code == 200)
{
if (resp.contains("manifest_text") && resp.contains("manifest"))
const QJsonObject envelope = resp.value(QStringLiteral("data")).isObject()
? resp.value(QStringLiteral("data")).toObject()
: resp;
QString manifestText = envelope.value(QStringLiteral("manifestText")).toString();
if (manifestText.isEmpty())
manifestText = envelope.value(QStringLiteral("manifest_text")).toString();
const QJsonObject manifest = envelope.value(QStringLiteral("manifest")).toObject();
if (!manifestText.isEmpty() && !manifest.isEmpty())
{
m_manifestText = resp["manifest_text"].toString();
m_manifest = resp["manifest"].toObject();
m_manifestText = manifestText;
m_manifest = manifest;
m_manifestSha256 = envelope.value(QStringLiteral("manifestSha256")).toString(
envelope.value(QStringLiteral("manifest_sha256")).toString());
m_manifestSignature = envelope.value(QStringLiteral("signature")).toString(
m_manifest.value(QStringLiteral("signature")).toString());
m_manifestSignatureAlg = envelope.value(QStringLiteral("signatureAlg")).toString(
envelope.value(QStringLiteral("signature_alg")).toString());
m_manifestKeyId = envelope.value(QStringLiteral("keyId")).toString(
envelope.value(QStringLiteral("key_id")).toString());
m_manifestSigned = envelope.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
m_fileItems.clear();
QJsonArray files = m_manifest.value("files").toArray();
@@ -59,21 +171,30 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
{
QJsonObject fileObj = fileItem.toObject();
FileDownloadItem fi;
fi.path = fileObj.value("path").toString();
fi.sha256 = fileObj.value("sha256").toString();
fi.size = fileObj.value("size").toVariant().toLongLong();
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL
fi.path = fileObj.value(QStringLiteral("path")).toString();
fi.sha256 = fileObj.value(QStringLiteral("sha256")).toString();
fi.size = manifestFileSize(fileObj);
fi.url = absoluteDownloadUrl(m_serverAddr,
fileObj.value(QStringLiteral("downloadUrl")).toString());
m_fileItems.append(fi);
}
}
else
{
qDebug() << "Manifest response missing fields";
m_error = QCoreApplication::translate("UpdaterLogic",
"Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4.")
.arg(appId, channel, targetVer, releaseId);
}
}
else
{
qDebug() << "Failed to get manifest";
const QString detail = serverDetailMessage(resp);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. Product: %2, channel: %3, version: %4, release id: %5.%6")
.arg(QString::number(code), appId, channel, targetVer, releaseId,
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
}
emit fetchUrlFinished();
});
@@ -86,12 +207,17 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
Q_UNUSED(signatureBase64);
Q_UNUSED(publicKeyPath);
qDebug() << "OpenSSL not available, cannot verify manifest signature";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot verify manifest signature because OpenSSL support is unavailable. Stage: manifest signature verification.");
return false;
#else
QFile keyFile(publicKeyPath);
if (!keyFile.open(QIODevice::ReadOnly))
{
qDebug() << "Cannot open public key file:" << publicKeyPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open manifest public key. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false;
}
@@ -102,6 +228,9 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!bio)
{
qDebug() << "BIO_new_mem_buf failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot parse manifest public key buffer. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false;
}
@@ -110,6 +239,9 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!pkey)
{
qDebug() << "PEM_read_bio_PUBKEY failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest public key is invalid. Stage: manifest signature verification. Public key path: %1.")
.arg(publicKeyPath);
return false;
}
@@ -119,6 +251,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
{
EVP_PKEY_free(pkey);
qDebug() << "EVP_MD_CTX_new failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create OpenSSL verification context. Stage: manifest signature verification.");
return false;
}
@@ -140,6 +274,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
if (!ok)
{
qDebug() << "Manifest signature verification failed";
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest RSA signature is invalid. Stage: manifest signature verification. This usually means the manifest was not signed by the matching server private key, the client public key is wrong, or the manifest content was changed.");
}
return ok;
#endif
@@ -147,17 +283,40 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
{
// 验签用的是客户端随 SDK 分发的公钥。
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
{
qDebug() << "No manifest available to verify";
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
return false;
}
QString signature = m_manifest.value("signature").toString();
if (signature.isEmpty())
{
qDebug() << "Manifest signature empty";
if (!m_manifestSha256.isEmpty()) {
const QString actualSha = QString::fromLatin1(
QCryptographicHash::hash(m_manifestText.toUtf8(), QCryptographicHash::Sha256).toHex());
if (actualSha.compare(m_manifestSha256, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest SHA-256 does not match the server envelope. Stage: manifest digest verification.\nExpected SHA-256: %1\nActual SHA-256: %2")
.arg(m_manifestSha256, actualSha);
return false;
}
}
const bool requireSignature = configFlag(QStringLiteral("require_manifest_signature"));
const QString signature = m_manifestSignature.trimmed();
if (signature.isEmpty() || !m_manifestSigned)
{
if (requireSignature) {
qDebug() << "Manifest signature empty";
m_error = QCoreApplication::translate("UpdaterLogic",
"The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification.");
return false;
}
qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled.";
m_error.clear();
return true;
}
QString path = publicKeyPath;
if (!QFile::exists(path))
@@ -169,50 +328,96 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
{
if (m_manifest.isEmpty())
// 启动后的完整性检查依赖本地 Manifest 缓存。
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot save manifest cache because the target manifest is empty. Stage: save target manifest cache.");
return false;
}
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath("."))
if (!dir.exists() && !dir.mkpath(".")) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create manifest cache directory. Stage: save target manifest cache. Directory: %1.")
.arg(cacheDir);
return false;
}
QString version = m_manifest.value("version").toString();
QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot write manifest cache file. Stage: save target manifest cache. File: %1. Error: %2.")
.arg(filePath, file.errorString());
return false;
}
QJsonObject wrapper;
wrapper["manifest"] = m_manifest;
wrapper["manifestText"] = m_manifestText;
wrapper["manifest_text"] = m_manifestText;
wrapper["manifestSha256"] = m_manifestSha256;
wrapper["signature"] = m_manifestSignature;
wrapper["signatureAlg"] = m_manifestSignatureAlg;
wrapper["keyId"] = m_manifestKeyId;
wrapper["signed"] = m_manifestSigned;
QJsonDocument doc(wrapper);
file.write(doc.toJson(QJsonDocument::Indented));
file.close();
qDebug() << "Manifest cached to" << filePath;
m_error.clear();
return true;
}
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
{
m_error.clear();
QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath);
if (!file.exists() || !file.open(QIODevice::ReadOnly))
if (!file.exists() || !file.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot read cached signed manifest. Stage: read local manifest cache. Version: %1. File: %2. This cache is created after a version is installed successfully.")
.arg(version, filePath);
return false;
}
QByteArray raw = file.readAll();
file.close();
QJsonDocument doc = QJsonDocument::fromJson(raw);
if (!doc.isObject())
if (!doc.isObject()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached signed manifest is not valid JSON. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath);
return false;
}
QJsonObject wrapper = doc.object();
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text"))
if (!wrapper.contains("manifest")
|| (!wrapper.contains("manifestText") && !wrapper.contains("manifest_text"))) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.")
.arg(version, filePath);
return false;
}
m_manifest = wrapper["manifest"].toObject();
m_manifestText = wrapper["manifest_text"].toString();
m_manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
if (m_manifestText.isEmpty())
m_manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
m_manifestSha256 = wrapper.value(QStringLiteral("manifestSha256")).toString(
wrapper.value(QStringLiteral("manifest_sha256")).toString());
m_manifestSignature = wrapper.value(QStringLiteral("signature")).toString(
m_manifest.value(QStringLiteral("signature")).toString());
m_manifestSignatureAlg = wrapper.value(QStringLiteral("signatureAlg")).toString(
wrapper.value(QStringLiteral("signature_alg")).toString());
m_manifestKeyId = wrapper.value(QStringLiteral("keyId")).toString(
wrapper.value(QStringLiteral("key_id")).toString());
m_manifestSigned = wrapper.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
qDebug() << "Loaded cached manifest" << version;
m_error.clear();
return true;
}
@@ -264,33 +469,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
}
QSet<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
for (const QString& path : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + path);
}
QStringList obsolete;
QSet<QString> seen;
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
const QJsonObject item = value.toObject();
if (item.contains(QStringLiteral("required"))
&& !item.value(QStringLiteral("required")).toBool(true)) {
continue;
}
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
const QString folded = path.toCaseFolded();
if (!isSafeRelativePath(path) || protectedPaths.contains(folded)
if (!isSafeRelativePath(path) || isRuntimeProtectedPath(path)
|| newPaths.contains(folded) || seen.contains(folded))
continue;
seen.insert(folded);
@@ -302,8 +491,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
{
if (m_manifest.isEmpty())
m_error.clear();
const QString stage = installedDir.isEmpty()
? QCoreApplication::translate("UpdaterLogic", "installed version verification")
: QCoreApplication::translate("UpdaterLogic", "downloaded/staged file verification");
const QString version = m_manifest.value(QStringLiteral("version")).toString();
if (m_manifest.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"No manifest is available. Stage: %1. The updater cannot know which files and hashes should be verified.")
.arg(stage);
return false;
}
const QJsonArray files = m_manifest.value("files").toArray();
for (const auto& item : files)
@@ -311,8 +509,12 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
const QJsonObject fileObject = item.toObject();
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
const QString expectedSha = fileObject.value("sha256").toString();
if (!isSafeRelativePath(path))
if (!isSafeRelativePath(path)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest contains an unsafe file path. Stage: %1. Version: %2. Path: %3.")
.arg(stage, version, path);
return false;
}
if (isRuntimeProtectedPath(path)) {
qDebug() << "Runtime-protected manifest entry ignored:" << path;
continue;
@@ -325,16 +527,32 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
if (!QFile::exists(fullPath))
{
qDebug() << "Manifest file missing from staging and installation:" << path;
m_error = QCoreApplication::translate("UpdaterLogic",
"A required file is missing. Stage: %1. Version: %2. Manifest path: %3. Checked path: %4. If this is a downloaded update, the file was not downloaded or staged correctly; if this is startup verification, the installed file may have been deleted.")
.arg(stage, version, path, fullPath);
return false;
}
const qint64 expectedSize = manifestFileSize(fileObject);
if (expectedSize >= 0 && QFileInfo(fullPath).size() != expectedSize)
{
m_error = QCoreApplication::translate("UpdaterLogic",
"File size does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected size: %5 bytes\nActual size: %6 bytes")
.arg(stage, version, path, fullPath,
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
return false;
}
const QString actualSha = calcLocalFileSha256(fullPath);
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
{
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
m_error = QCoreApplication::translate("UpdaterLogic",
"File SHA-256 does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected SHA-256: %5\nActual SHA-256: %6\nIf this happens during download, clear the update cache and retry. If this happens during startup or after installation, the local file differs from the published version.")
.arg(stage, version, path, fullPath, expectedSha, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
return false;
}
}
qDebug() << "Full manifest validation passed using staging plus installed files";
m_error.clear();
return true;
}
@@ -364,7 +582,14 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; }
manifest.insert("signature", wrapper.value("manifest_signature").toString());
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; }
m_manifestSha256 = packageMeta.value("manifest_sha256").toString();
m_manifestSignature = wrapper.value("manifest_signature").toString();
m_manifestSignatureAlg = wrapper.value("signature_alg").toString("RSA-SHA256");
m_manifestKeyId = wrapper.value("key_id").toString();
m_manifestSigned = !m_manifestSignature.isEmpty();
const QString manifestProduct = manifest.value("productCode").toString(manifest.value("app_id").toString());
const QString packageProduct = packageMeta.value("productCode").toString(packageMeta.value("app_id").toString());
if (manifestProduct != packageProduct || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; }
if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid"); return false; }
const qint64 payloadStart = 16 + qint64(headerSize);
const QJsonArray entries = packageMeta.value("files").toArray();
@@ -373,6 +598,7 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-range data: %1").arg(path); return false; }
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
if (isRuntimeProtectedPath(path)) continue;
if (stagingDir.isEmpty()) continue;
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare offline file: %1").arg(path); return false; }
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create staged file: %1").arg(path); return false; }
@@ -386,42 +612,18 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{
QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = targetVer;
body["version_id"] = versionId;
QJsonArray emptyFiles;
body["files"] = emptyFiles;
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
{
qDebug() << "Download URL API returned code:" << code;
m_fileItems.clear();
if (code == 200)
{
QJsonArray fileArr = resp["files"].toArray();
for (auto item : fileArr)
{
QJsonObject obj = item.toObject();
FileDownloadItem fi;
fi.path = obj["path"].toString();
fi.url = obj["url"].toString();
fi.sha256 = obj["sha256"].toString();
fi.size = obj["size"].toVariant().toLongLong();
m_fileItems.append(fi);
qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
}
}
else
{
qDebug() << "Failed to get download URL";
Q_UNUSED(appId);
Q_UNUSED(channel);
Q_UNUSED(targetVer);
Q_UNUSED(versionId);
m_error.clear();
if (m_fileItems.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads.");
} else {
qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest.";
}
emit fetchUrlFinished();
});
}
@@ -445,10 +647,18 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
const QString& resumePartPath)
{
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
if (!QDir().mkpath(QFileInfo(partPath).path())) return false;
const QString displayPath = m_currentDownloadPath.isEmpty() ? savePath : m_currentDownloadPath;
if (!QDir().mkpath(QFileInfo(partPath).path())) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create partial download directory. Stage: download file. File: %1. Partial file: %2.")
.arg(displayPath, partPath);
return false;
}
if (QFile::exists(savePath)
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0)
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) {
m_error.clear();
return true;
}
QFile::remove(savePath);
for (int attempt = 0; attempt < 4; ++attempt)
@@ -464,8 +674,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{
QFile::remove(savePath);
return QFile::rename(partPath, savePath);
if (QFile::rename(partPath, savePath)) {
m_error.clear();
return true;
}
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
}
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Cached partial file has the expected size but wrong SHA-256. Stage: resume download. File: %1.\nExpected SHA-256: %2\nActual SHA-256: %3\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, expectSha256, actualSha);
QFile::remove(partPath);
existingSize = 0;
}
@@ -476,13 +697,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (!partFile.open(mode))
{
qDebug() << "Cannot open partial download:" << partPath;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot open partial download file. Stage: download file. File: %1. Partial file: %2. Error: %3.")
.arg(displayPath, partPath, partFile.errorString());
return false;
}
QNetworkAccessManager manager;
manager.setProxy(QNetworkProxy::NoProxy);
QNetworkRequest request(url);
QNetworkRequest request{QUrl(url)};
request.setTransferTimeout(60000);
const QString clientToken = configValue(QStringLiteral("client_token"));
if (!clientToken.isEmpty())
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
if (existingSize > 0)
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
@@ -529,19 +756,37 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
if (QFile::rename(partPath, savePath))
{
qDebug() << "Download completed/resumed & sha pass:" << savePath;
m_error.clear();
return true;
}
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
.arg(displayPath, partPath, savePath);
return false;
}
else if (expectedSize >= 0 && actualSize >= expectedSize)
{
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
const QString actualSha = calcLocalFileSha256(partPath);
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file does not match the signed manifest. Stage: download file. File: %1. HTTP status: %2.\nExpected size: %3 bytes\nActual size: %4 bytes\nExpected SHA-256: %5\nActual SHA-256: %6\nThe partial cache will be deleted and downloaded again.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize),
expectSha256, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
QFile::remove(partPath);
}
else {
m_error = QCoreApplication::translate("UpdaterLogic",
"Downloaded file is incomplete. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes, current size: %4 bytes.")
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize));
}
}
else
{
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
m_error = QCoreApplication::translate("UpdaterLogic",
"Download request failed. Stage: download file. File: %1. Attempt: %2/4. HTTP status: %3. Network error: %4. Partial file: %5.")
.arg(displayPath, QString::number(attempt + 1), QString::number(httpStatus), networkError, partPath);
}
if (attempt < 3)
@@ -556,42 +801,23 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
}
}
}
if (m_error.isEmpty()) {
m_error = QCoreApplication::translate("UpdaterLogic",
"File download failed after retries. Stage: download file. File: %1.")
.arg(displayPath);
}
return false;
}
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(normalized);
return UpdatePathPolicy::isFullUpdateProtectedPath(
path, ConfigHelper::instance().runtimeRelativePath());
}
bool UpdaterLogic::isSafeRelativePath(const QString& path) const
{
const QString normalized = QDir::fromNativeSeparators(path);
const QString clean = QDir::cleanPath(normalized);
return !clean.isEmpty()
&& !QDir::isAbsolutePath(clean)
&& clean != ".."
&& !clean.startsWith("../")
&& !clean.contains(":");
return UpdatePathPolicy::isSafeRelativePath(path);
}
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
@@ -622,18 +848,29 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
{
m_error.clear();
if (m_fileItems.isEmpty())
{
m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"The target version manifest contains no downloadable files. Stage: prepare downloads.");
return false;
}
QDir stagingDir(tempDir);
if (!FileHelper::createDir(tempDir))
if (!FileHelper::createDir(tempDir)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create update staging directory. Stage: prepare downloads. Directory: %1.")
.arg(tempDir);
return false;
}
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
if (!FileHelper::createDir(resumeCacheDir))
if (!FileHelper::createDir(resumeCacheDir)) {
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create download cache directory. Stage: prepare downloads. Directory: %1.")
.arg(resumeCacheDir);
return false;
}
QSet<QString> activePartialNames;
for (const auto& item : m_fileItems)
activePartialNames.insert(item.sha256.toLower() + ".part");
@@ -663,6 +900,9 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
{
qDebug() << "Unsafe relative path in manifest:" << fi.path;
m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"Manifest contains an unsafe file path. Stage: prepare file download. Path: %1.")
.arg(fi.path);
return false;
}
@@ -685,6 +925,9 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
{
qDebug() << "Cannot create staging subdirectory:" << parentDir;
m_downloadAllOk = false;
m_error = QCoreApplication::translate("UpdaterLogic",
"Cannot create staging subdirectory. Stage: prepare file download. File: %1. Directory: %2.")
.arg(relativePath, parentDir);
return false;
}
@@ -706,19 +949,18 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
}
m_downloadAllOk = true;
m_error.clear();
return true;
}
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
const QString& version, bool success)
{
QJsonArray files;
for (const FileDownloadItem& item : m_fileItems)
files.append(QJsonObject{{"path", item.path}, {"size", item.size}});
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version},
{"result", success ? "success" : "fail"}, {"files", files}};
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
Q_UNUSED(appId);
Q_UNUSED(channel);
Q_UNUSED(version);
Q_UNUSED(success);
qDebug() << "Download result report is not part of the current SimCAE Hub API; skipped.";
}
void UpdaterLogic::reportResult(const QString& deviceId,
@@ -726,24 +968,11 @@ void UpdaterLogic::reportResult(const QString& deviceId,
const QString& toVer,
bool success)
{
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id");
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
if (success)
body["result"] = "success";
else
body["result"] = "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
Q_UNUSED(resp);
qDebug() << "Update result report returned code:" << code;
});
Q_UNUSED(deviceId);
Q_UNUSED(fromVer);
Q_UNUSED(toVer);
Q_UNUSED(success);
qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
}
QList<FileDownloadItem> UpdaterLogic::getFileList() const
+9 -1
View File
@@ -24,13 +24,15 @@ class UpdaterLogic : public QObject
public:
explicit UpdaterLogic(QObject* parent = nullptr);
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
void getManifest(const QString& appId, const QString& channel, const QString& targetVer,
int versionId, const QString& releaseId = QString());
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version);
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
QString offlineError() const { return m_offlineError; }
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);
@@ -62,6 +64,11 @@ private:
QString m_serverAddr;
QJsonObject m_manifest;
QString m_manifestText;
QString m_manifestSha256;
QString m_manifestSignature;
QString m_manifestSignatureAlg;
QString m_manifestKeyId;
bool m_manifestSigned = false;
QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0;
@@ -69,5 +76,6 @@ private:
qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath;
QString m_offlineError;
mutable QString m_error;
QElapsedTimer m_downloadTimer;
};
+66 -21
View File
@@ -1,10 +1,11 @@
#include <QApplication>
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QMessageBox>
#include <QProcess>
#include <QProgressDialog>
@@ -23,7 +24,7 @@
int main(int argc, char* argv[])
{
QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater");
QApplication::setApplicationName("SimCAE Updater");
QTranslator translator;
if (translator.load(":/i18n/update-client_zh_CN.qm"))
app.installTranslator(&translator);
@@ -37,6 +38,7 @@ int main(int argc, char* argv[])
QString appId;
QString channel;
QString targetVersion;
QString releaseId;
int targetVersionId = 0;
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
@@ -65,6 +67,8 @@ int main(int argc, char* argv[])
const QString arg = argv[i];
if (arg.startsWith("--bootstrap-resume="))
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
else if (arg.startsWith("--release-id="))
releaseId = arg.mid(QString("--release-id=").size());
}
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath();
@@ -73,7 +77,13 @@ int main(int argc, char* argv[])
const QString targetDir = config.installRoot();
const QString updateDir = config.updateRoot();
QDir().mkpath(updateDir);
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
QString configuredProductCode = config.getValue("App", "product_code").trimmed();
if (configuredProductCode.isEmpty())
configuredProductCode = config.getValue("App", "app_id").trimmed();
QString configuredChannel = config.getValue("App", "channel").trimmed();
if (configuredChannel.isEmpty())
configuredChannel = QStringLiteral("stable");
if (appId != configuredProductCode || channel != configuredChannel) {
QMessageBox::critical(nullptr,
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
@@ -172,13 +182,18 @@ int main(int argc, char* argv[])
QMessageBox::critical(nullptr, title, message);
return -1;
};
const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
const auto withDetails = [](const QString& message, const QString& details) {
return details.trimmed().isEmpty()
? message
: message + QCoreApplication::translate("Updater", "\n\nDetails:\n%1").arg(details);
};
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
const auto configuredName = [&](const QString& key, const QString& fallback) {
return ConfigHelper::executableNameForCurrentPlatform(
config.getValue("Runtime", key), fallback);
};
const QString mainExecutable = configuredName("main_executable", "MainApp");
const QString updaterExecutable = configuredName("updater_executable", "Updater");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
@@ -186,19 +201,38 @@ int main(int argc, char* argv[])
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
const QString launchToken = config.getValue("App", "launch_token");
QString mainStartupError;
const auto launchMainApp = [&](const QString& healthFile = QString()) {
mainStartupError.clear();
if (!QFileInfo::exists(mainAppPath)) {
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
.arg(mainAppPath);
return false;
}
QString ticketPath;
QString ticketError;
const QString launchVersion = config.getValue("App", "current_version");
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
&ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
.arg(mainAppPath, ticketError);
return false;
}
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
const bool started = QProcess::startDetached(mainAppPath, args);
if (!started) QFile::remove(ticketPath);
if (!started) {
QFile::remove(ticketPath);
mainStartupError = QCoreApplication::translate(
"Updater",
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nHealth file: %3\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
.arg(mainAppPath, ticketPath, healthFile.isEmpty() ? QCoreApplication::translate("Updater", "<not used>") : healthFile);
}
return started;
};
const auto bootstrapPlanFile = [&]() {
@@ -282,16 +316,19 @@ int main(int argc, char* argv[])
if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."));
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
logic.errorString()));
} else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId);
logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId);
}
if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap)
return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"),
QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."));
withDetails(QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
logic.errorString()),
"manifest_signature_invalid");
}
QStringList obsoletePaths;
@@ -309,9 +346,11 @@ int main(int argc, char* argv[])
if (!logic.saveManifestCache(manifestCacheDir)) {
if (resumingFromBootstrap)
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."),
logic.errorString()));
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
logic.errorString()),
"manifest_cache_failed");
}
@@ -324,7 +363,8 @@ int main(int argc, char* argv[])
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
if (logic.getFileList().isEmpty())
return fail(QCoreApplication::translate("Updater", "No Files to Update"),
QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
withDetails(QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
logic.errorString()),
"empty_file_list");
QStorageInfo storage(updateDir);
@@ -353,7 +393,8 @@ int main(int argc, char* argv[])
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
logic.reportDownloadResult(appId, channel, targetVersion, false);
return fail(QCoreApplication::translate("Updater", "Download Failed"),
QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network and try again."),
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);
@@ -362,7 +403,8 @@ int main(int argc, char* argv[])
setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files..."));
if (!logic.validateLocalFiles(stagingDir, targetDir))
return fail(QCoreApplication::translate("Updater", "File Verification Failed"),
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
withDetails(QCoreApplication::translate("Updater", "The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files."),
logic.errorString()),
"staging_verify_failed");
QStringList changedPaths;
@@ -441,7 +483,8 @@ int main(int argc, char* argv[])
setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result..."));
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"),
QCoreApplication::translate("Updater", "New version files failed verification after installation."));
withDetails(QCoreApplication::translate("Updater", "New version files failed verification after installation. The updater will roll back to the previous version."),
logic.errorString()));
for (const QString& path : transaction.obsoletePaths()) {
if (QFile::exists(QDir(targetDir).filePath(path)))
return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
@@ -459,7 +502,9 @@ int main(int argc, char* argv[])
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation..."));
if (!launchMainApp(healthFile))
return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"),
QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable));
mainStartupError.isEmpty()
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
: mainStartupError);
QElapsedTimer healthTimer;
healthTimer.start();
-22
View File
@@ -1,22 +0,0 @@
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
+3
View File
@@ -0,0 +1,3 @@
{
"api_base_url": "http://192.168.1.158:18000"
}
+5
View File
@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/simcae">
<file>server_config.json</file>
</qresource>
</RCC>
+176
View File
@@ -0,0 +1,176 @@
客户端国际化说明
================
这份文档说明 Qt 国际化文件怎么维护,以及哪些步骤是自动的、哪些步骤需要你手动做。
先看结论
========
Visual Studio 和 PowerShell 二选一即可。
- Visual Studio 是图形界面入口。
- PowerShell 是命令行入口。
- 两者最终调用的是同一套 CMake 目标,不是两套流程。
最重要的规则:
1. 普通“全部重新生成”会自动把已有的 update-client_zh_CN.ts 编译成 update-client_zh_CN.qm。
2. 普通“全部重新生成”不会自动扫描源码生成新的 update-client_zh_CN.ts 条目。
3. 如果新增了 QObject::tr(...)、QCoreApplication::translate(...) 这类新文案,必须先手动生成一次 update_client_lupdate。
4. 你编辑完 update-client_zh_CN.ts 后,再“全部重新生成”,CMake 会自动生成 .qm,并通过 qrc 打进程序。
文件说明
========
1. update-client_zh_CN.ts
翻译源文件,XML 格式。新增或修改代码里的 tr()/translate() 文案后,需要更新这个文件,再补中文翻译。
2. update-client_zh_CN.qm
Qt 运行时加载的二进制翻译文件。它由 .ts 编译生成,不要手工编辑。
3. update-client.qrc
Qt 资源文件。它会把 update-client_zh_CN.qm 编进 Launcher、Updater、Bootstrap、MainApp,不需要把 .qm 单独放到安装目录。
日常编译:没有新增界面文字
==========================
这种情况最简单。
你只是改了普通 C++ 代码,或者只是修改了 update-client_zh_CN.ts 里已有条目的中文翻译:
Visual Studio
```text
选择 x64 Release 或 x64 Debug -> 全部重新生成
```
PowerShell 等价命令:
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release
```
这时 CMake 会自动执行 lrelease
```text
update-client_zh_CN.ts -> update-client_zh_CN.qm
```
然后 .qm 会通过 update-client.qrc 打进 exe。
新增界面文字后的完整流程
========================
如果代码里新增了这些文字:
```cpp
QObject::tr("New message")
QCoreApplication::translate("Context", "New message")
```
只点“全部重新生成”是不够的。因为“全部重新生成”不会自动扫描源码,把新 source 写进 .ts。
正确流程是:
1. 先更新 .ts 文件。
Visual Studio
```text
在 CMake 目标里找到 update_client_lupdate,然后生成这个目标。
```
PowerShell 等价命令:
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release --target update_client_lupdate
```
这一步会扫描 Common、Bootstrap、Launcher、Updater、MainApp 里的 cpp/h 文件,把新增的 tr()/translate() 文案写入:
```text
i18n/update-client_zh_CN.ts
```
2. 编辑 update-client_zh_CN.ts。
找到新增的 `<source>...</source>`,把对应 `<translation>...</translation>` 补成中文。
可以用 Qt Linguist 打开,也可以直接用文本编辑器编辑 XML。
3. 再重新生成程序。
Visual Studio
```text
全部重新生成
```
PowerShell 等价命令:
```powershell
cmake --build --preset x64-release
```
这一步会自动做:
```text
update-client_zh_CN.ts -> update-client_zh_CN.qm -> update-client.qrc -> exe
```
如果只想单独生成 .qm
====================
一般不需要单独做。普通编译会自动生成 .qm。
如果你只是想检查 .ts 能不能正常编译成 .qm,可以单独生成这个目标:
Visual Studio
```text
生成 CMake 目标 update_client_translations
```
PowerShell
```powershell
cd C:\Users\admin\Desktop\update-client
cmake --build --preset x64-release --target update_client_translations
```
常见问题
========
1. 新增了 tr(),为什么程序里没有中文?
通常是少做了 update_client_lupdate。新增文案后必须先更新 .ts,再补中文,再重新生成。
2. 我只改了 .ts 里的中文,还要跑 update_client_lupdate 吗?
不需要。直接“全部重新生成”即可,CMake 会自动重新生成 .qm。
3. update-client_zh_CN.qm 要不要交付到安装目录?
不需要。它已经通过 update-client.qrc 编进 exe。
4. 代码里能不能直接写中文?
不建议。界面文字统一写英文 source,然后在 .ts 里翻译成中文。
5. Visual Studio 或 CMake 找不到 lupdate / lrelease 怎么办?
通常是 Qt 环境变量没配好。确认 CMAKE_PREFIX_PATH 或 Qt5_DIR 指向 Qt 目录。
Windows 示例:
```powershell
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
```
Linux 示例:
```bash
sudo apt install -y qttools5-dev-tools
```
Binary file not shown.
File diff suppressed because it is too large Load Diff
+20 -21
View File
@@ -1,33 +1,32 @@
客户端脚本说明
==============
SimCAE Hub 客户端脚本说明
==========================
本目录保存 update-client 的辅助脚本。项目根目录只保留源码、CMake 入口、Docs 和配置模板,脚本统一放在这里。
本目录保存 Qt/C++ 客户端更新链路的辅助脚本。客户端仍然由
Launcher、Updater、Bootstrap 和业务主程序组成,服务端接口使用当前
SimCAE Hub 的 Go API。
脚本列表:
1. package-sdk.ps1
生成给其他软件接入用的 UpdateClientSDK 包。
在 Windows 上生成给业务软件接入用的客户端更新运行时包。
2. package-client.ps1
生成某个具体产品的最终客户端发布包
Windows 本地调试用的客户包脚本,需要手工提供 app_config.json
新流程建议上传完整软件 ZIP 到 SimCAE Hub,由服务端生成最终配置。
3. install-sdk.ps1
将 SDK 运行时复制到业务软件 Release 目录。
把客户端更新运行时复制到业务软件 Release 目录。
推荐在 update-client 根目录执行:
4. package-sdk.sh
在 Linux 上生成客户端更新运行时包,输出 tar.gz。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
5. package-client.sh
Linux 本地调试用的客户包脚本,需要手工提供 app_config.json。
```powershell
.\scripts\package-client.ps1 `
-SourceDir .\out\bin `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
SDK 打包命令、两种打包模式、参数含义和输出位置,统一看:
../updater打包成SDK.md
生成 SDK 后,SDK 根目录里只带给 SIMCAE 发布人员看的 SIMCAE打包上传.md。
后续如何把 Launcher、Updater、Bootstrap 和必要运行库放进业务软件、如何
组装 Qt IFW 交付包并上传,以该文档为准。
+2 -16
View File
@@ -4,8 +4,6 @@ param(
[string]$ReleaseDir = (Get-Location).Path,
[switch]$OverwriteConfig,
[switch]$IncludeQtRuntime
)
@@ -15,11 +13,8 @@ $sdk = (Resolve-Path $SdkRoot).Path
$release = (Resolve-Path $ReleaseDir).Path
$binDir = Join-Path $sdk "bin"
$configDir = Join-Path $sdk "config"
$appConfig = Join-Path $configDir "app_config.json"
$publicKey = Join-Path $configDir "manifest_public_key.pem"
foreach ($path in @($binDir, $appConfig, $publicKey)) {
foreach ($path in @($binDir)) {
if (-not (Test-Path $path)) {
throw "SDK file is missing: $path"
}
@@ -61,14 +56,5 @@ Get-ChildItem $binDir -Force | Where-Object {
$targetConfigDir = Join-Path $release "config"
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
$targetAppConfig = Join-Path $targetConfigDir "app_config.json"
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
Copy-Item $appConfig $targetAppConfig -Force
} else {
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
}
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force
Write-Host "SDK files installed to: $release"
Write-Host "Next: edit config/app_config.json, then start Launcher.exe."
Write-Host "Next: package the whole application directory and upload it in SimCAE Hub. The server will generate config/app_config.json and config/manifest_public_key.pem when needed."
+44 -18
View File
@@ -3,20 +3,21 @@ param(
[Parameter(Mandatory = $true)]
[string]$ConfigFile,
[string]$OutputDir = "",
[string]$ZipFile = ""
[string]$ZipFile = "",
[switch]$SkipManifestCheck
)
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin"
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
}
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClient"
$OutputDir = Join-Path $RepoRoot "dist/SimCAEUpdateClient"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClient.zip"
$ZipFile = Join-Path $RepoRoot "dist/SimCAEUpdateClient.zip"
}
$source = (Resolve-Path $SourceDir).Path
@@ -48,9 +49,27 @@ function Join-RelativePath([string]$Base, [string]$Child) {
return "$baseNorm/$childNorm"
}
function Get-InstallDirectoryId([string]$RuntimeDir) {
$normalized = ([IO.Path]::GetFullPath($RuntimeDir) -replace '\\', '/').TrimEnd('/')
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
$hash = $sha.ComputeHash($bytes)
return -join ($hash | ForEach-Object { $_.ToString("x2") })
} finally {
$sha.Dispose()
}
}
function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestName) {
$localData = [Environment]::GetFolderPath("LocalApplicationData")
if ([string]::IsNullOrWhiteSpace($localData)) { return "" }
$installId = Get-InstallDirectoryId $RuntimeDir
return Join-Path $localData "SimCAE\HubUpdateClient\installations\$installId\update\manifest_cache\$ManifestName"
}
$requiredFields = @(
"app_id", "channel", "api_base_url", "current_version",
"client_token", "launch_token", "license_key",
"product_code", "channel", "current_version", "launch_token",
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
)
foreach ($field in $requiredFields) {
@@ -82,7 +101,7 @@ $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Extension -in @('.pdb', '.ilk')
}
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
@@ -96,16 +115,21 @@ if ($duplicateMain) {
}
$manifestName = "manifest_$($settings.current_version).json"
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
if (-not (Test-Path $sourceManifest)) {
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
if (Test-Path $legacySourceManifest) {
$sourceManifest = $legacySourceManifest
}
$runtimeDirAbsolute = if ([string]::IsNullOrWhiteSpace($runtimeDirRelative)) {
$source
} else {
Join-Path $source ($runtimeDirRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
}
if (-not (Test-Path $sourceManifest)) {
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
$manifestCandidates = @(
(Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
(Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
(Join-Path $source "update/manifest_cache/$manifestName")
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
$sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $SkipManifestCheck -and (-not $sourceManifest -or -not (Test-Path $sourceManifest))) {
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
throw "Missing Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging, or pass -SkipManifestCheck for a first-time test package. Searched paths:$([Environment]::NewLine)$searched"
}
if (Test-Path $OutputDir) {
@@ -137,8 +161,10 @@ Copy-Item $config $outputConfigPath -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
if ($sourceManifest -and (Test-Path $sourceManifest)) {
New-Item $manifestDir -ItemType Directory -Force | Out-Null
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
}
$zipParent = Split-Path $ZipFile -Parent
New-Item $zipParent -ItemType Directory -Force | Out-Null
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
CONFIG_FILE=""
OUTPUT_DIR="$REPO_ROOT/dist/SimCAEUpdateClient-linux"
ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEUpdateClient-linux.tar.gz"
SKIP_MANIFEST_CHECK=0
usage() {
cat <<'EOF'
Usage: package-client.sh --config-file FILE [options]
Options:
--source-dir DIR Release/install root to package. Default: ./out/linux/bin
--config-file FILE app_config.json used by this client package. Required.
--output-dir DIR Output directory. Default: ./dist/SimCAEUpdateClient-linux
--archive FILE Output tar.gz. Default: ./dist/SimCAEUpdateClient-linux.tar.gz
--skip-manifest-check Skip current-version manifest cache check.
-h, --help Show this help.
EOF
}
normalize_relative_path() {
local value="${1:-}"
value="${value//\\//}"
value="${value#/}"
value="${value%/}"
printf '%s' "$value"
}
join_relative_path() {
local base
local child
base="$(normalize_relative_path "${1:-}")"
child="$(normalize_relative_path "${2:-}")"
if [[ -z "$base" ]]; then printf '%s' "$child"; return; fi
if [[ -z "$child" ]]; then printf '%s' "$base"; return; fi
printf '%s/%s' "$base" "$child"
}
json_value() {
python3 - "$CONFIG_FILE" "$1" <<'PY'
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8-sig") as f:
data = json.load(f)
value = data.get(sys.argv[2], "")
print("" if value is None else value)
PY
}
relative_to_source() {
local full="$1"
local fallback="$2"
python3 - "$SOURCE_DIR" "$full" "$fallback" <<'PY'
import os
import sys
source = os.path.realpath(sys.argv[1])
full = os.path.realpath(sys.argv[2])
fallback = sys.argv[3]
try:
rel = os.path.relpath(full, source)
except ValueError:
rel = fallback
if rel.startswith(".."):
rel = fallback
print(rel.replace(os.sep, "/").strip("/"))
PY
}
install_directory_id() {
python3 - "$1" <<'PY'
import hashlib
import os
import sys
value = os.path.realpath(sys.argv[1]).replace(os.sep, "/").rstrip("/")
print(hashlib.sha256(value.encode("utf-8")).hexdigest())
PY
}
user_data_manifest_candidate() {
local runtime_dir="$1"
local manifest_name="$2"
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
local install_id
install_id="$(install_directory_id "$runtime_dir")"
printf '%s/SimCAE/HubUpdateClient/installations/%s/update/manifest_cache/%s' \
"$data_home" "$install_id" "$manifest_name"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--config-file) CONFIG_FILE="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--skip-manifest-check) SKIP_MANIFEST_CHECK=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [[ -z "$CONFIG_FILE" ]]; then
echo "--config-file is required." >&2
usage >&2
exit 2
fi
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
CONFIG_FILE="$(realpath "$CONFIG_FILE")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
for field in product_code channel current_version launch_token main_executable launcher_executable updater_executable bootstrap_executable; do
if [[ -z "$(json_value "$field")" ]]; then
echo "Config file is missing required field: $field" >&2
exit 1
fi
done
CONFIG_RELATIVE_PATH="$(relative_to_source "$CONFIG_FILE" "config/app_config.json")"
CONFIG_RELATIVE_PARENT="$(dirname "$CONFIG_RELATIVE_PATH")"
[[ "$CONFIG_RELATIVE_PARENT" == "." ]] && CONFIG_RELATIVE_PARENT=""
RUNTIME_DIR_RELATIVE="$(normalize_relative_path "$(dirname "$CONFIG_RELATIVE_PARENT")")"
[[ "$RUNTIME_DIR_RELATIVE" == "." ]] && RUNTIME_DIR_RELATIVE=""
MAIN_EXECUTABLE="$(normalize_relative_path "$(json_value main_executable)")"
LAUNCHER_EXECUTABLE="$(normalize_relative_path "$(json_value launcher_executable)")"
UPDATER_EXECUTABLE="$(normalize_relative_path "$(json_value updater_executable)")"
BOOTSTRAP_EXECUTABLE="$(normalize_relative_path "$(json_value bootstrap_executable)")"
CURRENT_VERSION="$(json_value current_version)"
for required in \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")"; do
if [[ ! -f "$SOURCE_DIR/$required" ]]; then
echo "Source directory is missing required file: $required" >&2
exit 1
fi
done
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "Source directory contains Debug artifacts. Use a clean Release root directory." >&2
echo "Example: $DEBUG_ARTIFACT" >&2
exit 1
fi
EXPECTED_MAIN_PATH="$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")"
MAIN_LEAF="$(basename "$MAIN_EXECUTABLE")"
DUPLICATE_MAIN="$({ find "$SOURCE_DIR" -type f -name "$MAIN_LEAF" | while read -r item; do
rel="$(python3 - "$SOURCE_DIR" "$item" <<'PY'
import os, sys
print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])).replace(os.sep, "/"))
PY
)"
[[ "$rel" != "$EXPECTED_MAIN_PATH" ]] && { echo "$item"; break; }
done; } || true)"
if [[ -n "$DUPLICATE_MAIN" ]]; then
echo "Source directory contains a duplicate main executable outside $EXPECTED_MAIN_PATH: $DUPLICATE_MAIN" >&2
exit 1
fi
MANIFEST_NAME="manifest_${CURRENT_VERSION}.json"
if [[ -z "$RUNTIME_DIR_RELATIVE" ]]; then
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR"
else
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR/$RUNTIME_DIR_RELATIVE"
fi
MANIFEST_CANDIDATES=(
"$(user_data_manifest_candidate "$RUNTIME_DIR_ABSOLUTE" "$MANIFEST_NAME")"
"$SOURCE_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache/$MANIFEST_NAME")"
"$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME"
)
SOURCE_MANIFEST=""
for candidate in "${MANIFEST_CANDIDATES[@]}"; do
if [[ -f "$candidate" ]]; then
SOURCE_MANIFEST="$candidate"
break
fi
done
if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then
echo "Missing Manifest cache for current version: $MANIFEST_NAME." >&2
echo "Complete online update/verification for this version before packaging, or pass --skip-manifest-check for a first-time test package. Searched paths:" >&2
printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR"
shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do
base="$(basename "$item")"
case "$base" in
update|update_temp) ;;
*) cp -a "$item" "$OUTPUT_DIR/" ;;
esac
done
shopt -u dotglob nullglob
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update")"
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update_temp")"
OUTPUT_CONFIG_PATH="$OUTPUT_DIR/$CONFIG_RELATIVE_PATH"
mkdir -p "$(dirname "$OUTPUT_CONFIG_PATH")"
cp "$CONFIG_FILE" "$OUTPUT_CONFIG_PATH"
rm -f "$(dirname "$OUTPUT_CONFIG_PATH")/client_identity.dat" \
"$(dirname "$OUTPUT_CONFIG_PATH")/local_state.json" \
"$(dirname "$OUTPUT_CONFIG_PATH")/version_policy.dat"
if [[ -f "$SOURCE_MANIFEST" ]]; then
MANIFEST_DIR="$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache")"
mkdir -p "$MANIFEST_DIR"
cp "$SOURCE_MANIFEST" "$MANIFEST_DIR/$MANIFEST_NAME"
fi
chmod +x "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")" \
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" 2>/dev/null || true
mkdir -p "$(dirname "$ARCHIVE_FILE")"
rm -f "$ARCHIVE_FILE"
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
echo "Package directory: $OUTPUT_DIR"
echo "Archive file: $ARCHIVE_FILE"
+41 -31
View File
@@ -3,7 +3,6 @@ param(
[string]$OutputDir = "",
[string]$ZipFile = "",
[string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "",
[switch]$IncludeDemoMainApp,
[switch]$IncludeQtRuntime
)
@@ -11,21 +10,18 @@ param(
$ErrorActionPreference = "Stop"
$RepoRoot = Split-Path -Parent $PSScriptRoot
$DefaultSdkName = if ($IncludeQtRuntime) { "UpdateClientSDK-With-QtDll" } else { "UpdateClientSDK" }
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
$SourceDir = Join-Path $RepoRoot "out/bin"
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
}
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
$OutputDir = Join-Path $RepoRoot "dist/UpdateClientSDK"
$OutputDir = Join-Path $RepoRoot "dist/$DefaultSdkName"
}
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
$ZipFile = Join-Path $RepoRoot "dist/UpdateClientSDK.zip"
}
if ([string]::IsNullOrWhiteSpace($ExampleConfig)) {
$ExampleConfig = Join-Path $RepoRoot "config/app_config.example.json"
$ZipFile = Join-Path $RepoRoot "dist/$DefaultSdkName.zip"
}
$source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
foreach ($name in $requiredFiles) {
@@ -35,16 +31,6 @@ foreach ($name in $requiredFiles) {
}
}
$publicKeyCandidates = @(
(Join-Path $source "config/manifest_public_key.pem"),
(Join-Path $source "manifest_public_key.pem"),
(Join-Path $RepoRoot "config/manifest_public_key.pem")
)
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) {
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key."
}
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
$_.Extension -in @('.pdb', '.ilk')
@@ -59,7 +45,8 @@ New-Item $OutputDir -ItemType Directory -Force | Out-Null
$binDir = Join-Path $OutputDir "bin"
$configDir = Join-Path $OutputDir "config"
$scriptsDir = Join-Path $OutputDir "scripts"
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
$commonDir = Join-Path $OutputDir "Common"
New-Item $binDir,$configDir,$scriptsDir,$commonDir -ItemType Directory -Force | Out-Null
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
@@ -84,6 +71,11 @@ function Test-IsQtRuntimeFile {
return (
$Item.Name -match '^Qt5.*\.dll$' -or
$Item.Name -match '^vc_redist.*\.exe$' -or
$Item.Name -match '^vcredist.*\.exe$' -or
$Item.Name -match '^vcruntime.*\.dll$' -or
$Item.Name -match '^msvcp.*\.dll$' -or
$Item.Name -match '^concrt.*\.dll$' -or
$Item.Name -in @(
"libEGL.dll",
"libGLESv2.dll",
@@ -97,19 +89,32 @@ Get-ChildItem $source -Force | Where-Object {
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
} | Copy-Item -Destination $binDir -Recurse -Force
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
$wordGuideSource = @($RepoRoot, (Join-Path $RepoRoot "Docs")) |
Where-Object { Test-Path $_ } |
ForEach-Object { Get-ChildItem $_ -File -Filter "*.docx" } |
Where-Object { $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" } |
Sort-Object Name |
Select-Object -First 1
if (-not $wordGuideSource) {
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the repository root or Docs directory."
$commonSourceDir = Join-Path $RepoRoot "Common"
$commonSourceFiles = @(
"ConfigHelper.h",
"ConfigHelper.cpp",
"IntegrityHelper.h",
"IntegrityHelper.cpp",
"TicketHelper.h",
"TicketHelper.cpp",
"UpdatePathPolicy.h",
"UpdatePathPolicy.cpp"
)
foreach ($commonFile in $commonSourceFiles) {
$commonPath = Join-Path $commonSourceDir $commonFile
if (-not (Test-Path $commonPath)) {
throw "SDK Common integration source is missing: $commonPath"
}
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
}
$sdkGuideName = [string]::Concat("SIMCAE", [char]0x6253, [char]0x5305, [char]0x4e0a, [char]0x4f20, ".md")
$sdkGuideSource = Join-Path $RepoRoot $sdkGuideName
if (Test-Path $sdkGuideSource) {
Copy-Item $sdkGuideSource (Join-Path $OutputDir $sdkGuideName) -Force
} else {
throw "SIMCAE packaging guide is missing: $sdkGuideSource"
}
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force
Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
@@ -121,6 +126,11 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in
sdk_type = "external-updater-runtime"
required_entry = "Launcher.exe"
contains_demo_main_app = [bool]$IncludeDemoMainApp
contains_qt_runtime = [bool]$IncludeQtRuntime
contains_final_config = $false
docs_entry = $sdkGuideName
word_guide_included = $false
integration_sources = $commonSourceFiles
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
$zipParent = Split-Path $ZipFile -Parent
+155
View File
@@ -0,0 +1,155 @@
#!/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"
INCLUDE_DEMO_MAIN_APP=0
INCLUDE_QT_RUNTIME=0
usage() {
cat <<'EOF'
Usage: package-sdk.sh [options]
Options:
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
--output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux
--archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz
--sdk-version VERSION SDK version. Default: 0.1.0
--include-demo-mainapp Include MainApp demo executable in SDK bin.
--include-qt-runtime Include Qt runtime files from the Release output directory.
-h, --help Show this help.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
--sdk-version) SDK_VERSION="$2"; shift 2 ;;
--include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
--include-qt-runtime) INCLUDE_QT_RUNTIME=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
for name in Launcher Updater Bootstrap; do
if [[ ! -f "$SOURCE_DIR/$name" ]]; then
echo "SDK source directory is missing required file: $SOURCE_DIR/$name" >&2
exit 1
fi
done
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
if [[ -n "$DEBUG_ARTIFACT" ]]; then
echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2
echo "Example: $DEBUG_ARTIFACT" >&2
exit 1
fi
rm -rf "$OUTPUT_DIR"
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common"
is_qt_runtime_item() {
local base="$1"
case "$base" in
bearer|iconengines|imageformats|platforms|styles|translations)
return 0
;;
libQt5*.so*|libEGL.so*|libGLESv2.so*|libqxcb.so*|libxcb*.so*|libstdc++.so*|libgcc_s.so*|libssl.so*|libcrypto.so*|opengl32sw.dll|d3dcompiler_47.dll)
return 0
;;
*)
return 1
;;
esac
}
shopt -s dotglob nullglob
for item in "$SOURCE_DIR"/*; do
base="$(basename "$item")"
case "$base" in
config|update|update_temp|manifest_public_key.pem|MainApp)
if [[ "$base" == "MainApp" && "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]]; then
cp -a "$item" "$OUTPUT_DIR/bin/"
fi
;;
*)
if [[ "$INCLUDE_QT_RUNTIME" -eq 0 ]] && is_qt_runtime_item "$base"; then
continue
fi
cp -a "$item" "$OUTPUT_DIR/bin/"
;;
esac
done
shopt -u dotglob nullglob
for common_file in ConfigHelper.h ConfigHelper.cpp IntegrityHelper.h IntegrityHelper.cpp TicketHelper.h TicketHelper.cpp UpdatePathPolicy.h UpdatePathPolicy.cpp; do
common_path="$REPO_ROOT/Common/$common_file"
if [[ ! -f "$common_path" ]]; then
echo "SDK Common integration source is missing: $common_path" >&2
exit 1
fi
cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
done
SDK_GUIDE_NAME="SIMCAE打包上传.md"
SDK_GUIDE_SOURCE="$REPO_ROOT/$SDK_GUIDE_NAME"
if [[ -f "$SDK_GUIDE_SOURCE" ]]; then
cp "$SDK_GUIDE_SOURCE" "$OUTPUT_DIR/$SDK_GUIDE_NAME"
else
echo "SIMCAE packaging guide is missing: $SDK_GUIDE_SOURCE" >&2
exit 1
fi
cp "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh"
cp "$SCRIPT_DIR/package-client.sh" "$OUTPUT_DIR/scripts/package-client.sh"
if [[ -f "$SCRIPT_DIR/install-sdk.ps1" ]]; then cp "$SCRIPT_DIR/install-sdk.ps1" "$OUTPUT_DIR/scripts/install-sdk.ps1"; fi
if [[ -f "$SCRIPT_DIR/package-sdk.ps1" ]]; then cp "$SCRIPT_DIR/package-sdk.ps1" "$OUTPUT_DIR/scripts/package-sdk.ps1"; fi
if [[ -f "$SCRIPT_DIR/package-client.ps1" ]]; then cp "$SCRIPT_DIR/package-client.ps1" "$OUTPUT_DIR/scripts/package-client.ps1"; fi
chmod +x "$OUTPUT_DIR/bin/Launcher" "$OUTPUT_DIR/bin/Updater" "$OUTPUT_DIR/bin/Bootstrap" 2>/dev/null || true
chmod +x "$OUTPUT_DIR/scripts/package-sdk.sh" "$OUTPUT_DIR/scripts/package-client.sh"
cat > "$OUTPUT_DIR/sdk_manifest.json" <<EOF
{
"sdk_version": "$SDK_VERSION",
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"sdk_type": "external-updater-runtime",
"platform": "linux",
"required_entry": "Launcher",
"contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false),
"contains_qt_runtime": $([[ "$INCLUDE_QT_RUNTIME" -eq 1 ]] && echo true || echo false),
"contains_final_config": false,
"docs_entry": "$SDK_GUIDE_NAME",
"word_guide_included": false,
"integration_sources": [
"ConfigHelper.h",
"ConfigHelper.cpp",
"IntegrityHelper.h",
"IntegrityHelper.cpp",
"TicketHelper.h",
"TicketHelper.cpp",
"UpdatePathPolicy.h",
"UpdatePathPolicy.cpp"
]
}
EOF
mkdir -p "$(dirname "$ARCHIVE_FILE")"
rm -f "$ARCHIVE_FILE"
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
echo "SDK directory: $OUTPUT_DIR"
echo "SDK archive: $ARCHIVE_FILE"
echo "SDK version: $SDK_VERSION"
+124
View File
@@ -0,0 +1,124 @@
# Updater 打包成 SDK
本文只说明如何从 `SIMCAE/update-client` 生成给 SIMCAE 打包流程使用的更新客户端 SDK。SIMCAE 如何拿这个 SDK 打客户安装器和交付包,见当前目录《SIMCAE打包上传.md》。
## 一、SDK 包含什么
SDK 用来把 Hub 更新客户端接入 SIMCAE 安装包。
| 内容 | 作用 |
| --- | --- |
| `Launcher.exe` | 客户日常启动入口,检查整包更新并启动主程序 |
| `Updater.exe` | 拉取 Manifest、下载发布包、校验 SHA-256、准备安装 |
| `Bootstrap.exe` | 替换运行中文件时接管安装 |
| Qt 运行库 | 可选,给没有单独 Qt 运行环境的接入方使用 |
| `SIMCAE打包上传.md` | 给 SIMCAE 发布人员看的打包、交付包组装和上传说明 |
SDK 不包含最终客户配置文件,例如 `app_config.json``server_config.json``server_config.qrc``manifest_public_key.pem`。这些文件由服务端在上传客户软件包或 Qt IFW 交付包时生成或注入。
打包后的 SDK 根目录只放 `SIMCAE打包上传.md` 这一份使用说明。本文是维护者打 SDK 的说明,不随 SDK 一起交给接入方。
## 二、编译 Release
先进入 SIMCAE 仓库下的 `update-client` 目录。如果当前已经在 SIMCAE 仓库根目录:
```powershell
cd .\update-client
```
然后执行:
```powershell
cmake --preset x64-release -DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
cmake --build --preset x64-release
```
如果 OpenSSL 安装在其他目录,只改 `SIMCAE_OPENSSL_ROOT` 这一项。
编译完成后,Release 产物通常位于 `out/bin/Release`
检查核心程序:
```powershell
Test-Path .\out\bin\Release\Launcher.exe
Test-Path .\out\bin\Release\Updater.exe
Test-Path .\out\bin\Release\Bootstrap.exe
```
预期都返回 `True`
## 三、打包不带 Qt 运行库的 SDK
适用于接入方已经有 Qt 运行环境,或希望自己控制 Qt DLL 的情况。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
输出:
| 输出 | 说明 |
| --- | --- |
| `dist\UpdateClientSDK` | 不带 Qt 运行库的 SDK 展开目录 |
| `dist\UpdateClientSDK.zip` | 不带 Qt 运行库的 SDK 压缩包 |
## 四、打包带 Qt 运行库的 SDK
适用于接入方不想单独准备 Qt DLL,或者希望拿到后能直接放进安装包。
```powershell
.\scripts\package-sdk.ps1 `
-SourceDir .\out\bin\Release `
-OutputDir .\dist\UpdateClientSDK-With-QtDll `
-ZipFile .\dist\UpdateClientSDK-With-QtDll.zip `
-SdkVersion 0.1.0 `
-IncludeQtRuntime
```
这里的 `With-QtDll` 表示包里带的是运行所需的 Qt DLL,不是完整 Qt SDK。
输出:
| 输出 | 说明 |
| --- | --- |
| `dist\UpdateClientSDK-With-QtDll` | 带 Qt 运行库 DLL 的 SDK 展开目录 |
| `dist\UpdateClientSDK-With-QtDll.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
## 五、打包后检查
```powershell
Test-Path .\dist\UpdateClientSDK-With-QtDll\bin\Launcher.exe
Test-Path .\dist\UpdateClientSDK-With-QtDll\bin\Updater.exe
Test-Path .\dist\UpdateClientSDK-With-QtDll\bin\Bootstrap.exe
Test-Path .\dist\UpdateClientSDK-With-QtDll\SIMCAE打包上传.md
Test-Path .\dist\UpdateClientSDK-With-QtDll.zip
```
预期都返回 `True`
## 六、不要提交的内容
当前仓库的 `.gitignore` 已忽略这些本地内容:
- `thirdparty/`
- `out/`
- `dist/`
- `*.exe`
- `*.dll`
- `*.zip`
- `config/app_config.json`
- `config/client_identity.dat`
- `config/local_state.json`
- `config/version_policy.dat`
提交前看一下:
```powershell
git status --short
```
不要把本地依赖、编译产物、SDK ZIP、客户配置和运行状态提交进仓库。