feat: 支持 Linux 客户端打包与跨平台运行
This commit is contained in:
@@ -1,6 +1,19 @@
|
||||
project(Bootstrap LANGUAGES CXX)
|
||||
|
||||
add_executable(Bootstrap WIN32 main.cpp)
|
||||
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
||||
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
|
||||
target_link_libraries(Bootstrap PRIVATE shell32)
|
||||
project(Bootstrap LANGUAGES CXX)
|
||||
|
||||
add_executable(Bootstrap
|
||||
main.cpp
|
||||
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||
)
|
||||
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
||||
target_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
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()
|
||||
|
||||
+195
-186
@@ -1,191 +1,200 @@
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static std::wstring quote(const std::wstring& value)
|
||||
{
|
||||
std::wstring result = L"\"";
|
||||
unsigned backslashes = 0;
|
||||
for (wchar_t ch : value) {
|
||||
if (ch == L'\\') { ++backslashes; continue; }
|
||||
if (ch == L'\"') {
|
||||
result.append(backslashes * 2 + 1, L'\\');
|
||||
result.push_back(L'\"');
|
||||
backslashes = 0;
|
||||
continue;
|
||||
}
|
||||
result.append(backslashes, L'\\');
|
||||
backslashes = 0;
|
||||
result.push_back(ch);
|
||||
}
|
||||
result.append(backslashes * 2, L'\\');
|
||||
result.push_back(L'\"');
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::wstring fromUtf8(const std::string& 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;
|
||||
}
|
||||
|
||||
static bool safeRelative(const fs::path& path)
|
||||
{
|
||||
if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
|
||||
for (const auto& part : path)
|
||||
if (part == L"..") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(destination.parent_path(), ec);
|
||||
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));
|
||||
}
|
||||
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)
|
||||
{
|
||||
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)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
|
||||
const std::wstring& 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;
|
||||
}
|
||||
|
||||
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
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);
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QTextStream>
|
||||
#include <QThread>
|
||||
#include <QTranslator>
|
||||
#include <QVector>
|
||||
|
||||
struct PlanItem
|
||||
{
|
||||
QChar operation;
|
||||
QString relativePath;
|
||||
};
|
||||
|
||||
static void showError(const QString& message)
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QApplication::translate("Bootstrap", "Update Handoff Failed"),
|
||||
message);
|
||||
}
|
||||
|
||||
static QString cleanRelativePath(const QString& value)
|
||||
{
|
||||
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 QString joinPath(const QString& root, const QString& relativePath)
|
||||
{
|
||||
return QDir(root).filePath(relativePath);
|
||||
}
|
||||
|
||||
static bool removeWithRetry(const QString& path)
|
||||
{
|
||||
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 QString& source, const QString& destination)
|
||||
{
|
||||
QDir().mkpath(QFileInfo(destination).absolutePath());
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
QFile::remove(destination);
|
||||
if (QFile::copy(source, destination))
|
||||
return true;
|
||||
QThread::msleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool rollback(const QString& installDir, const QString& backupDir,
|
||||
const QVector<QString>& paths)
|
||||
{
|
||||
bool success = true;
|
||||
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 QString& updater, const QStringList& updateArgs,
|
||||
const QString& result)
|
||||
{
|
||||
QStringList args = updateArgs;
|
||||
args.append(QStringLiteral("--bootstrap-resume=%1").arg(result));
|
||||
return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath());
|
||||
}
|
||||
|
||||
static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
return 3;
|
||||
}
|
||||
|
||||
const QString planFile = arguments.at(1);
|
||||
const QString installDir = arguments.at(2);
|
||||
const QString stagingDir = arguments.at(3);
|
||||
const QString backupDir = arguments.at(4);
|
||||
const QString updater = arguments.at(5);
|
||||
const QString updaterPid = arguments.at(6);
|
||||
Q_UNUSED(updaterPid);
|
||||
const QStringList updateArgs{arguments.at(7), arguments.at(8), arguments.at(9), arguments.at(10)};
|
||||
const QString mode = arguments.size() >= 12 ? arguments.at(11) : QStringLiteral("install");
|
||||
|
||||
QThread::msleep(500);
|
||||
|
||||
QVector<PlanItem> items;
|
||||
QVector<QString> paths;
|
||||
if (!readPlan(planFile, &items, &paths)) {
|
||||
showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid."));
|
||||
return 3;
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
bool rolledBack = false;
|
||||
QString failedPath;
|
||||
if (mode == QStringLiteral("rollback")) {
|
||||
rolledBack = rollback(installDir, backupDir, paths);
|
||||
success = false;
|
||||
} else {
|
||||
for (const PlanItem& item : items) {
|
||||
const QString& relativePath = item.relativePath;
|
||||
if (isBootstrapSelfPath(relativePath)) {
|
||||
success = false;
|
||||
failedPath = relativePath;
|
||||
break;
|
||||
}
|
||||
|
||||
const bool itemOk = item.operation == QLatin1Char('C')
|
||||
? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath))
|
||||
: removeWithRetry(joinPath(installDir, relativePath));
|
||||
if (!itemOk) {
|
||||
success = false;
|
||||
failedPath = relativePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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 rolledBack = false;
|
||||
fs::path failedPath;
|
||||
if (mode == L"rollback") {
|
||||
rolledBack = success && 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) {
|
||||
success = false;
|
||||
failedPath = relative;
|
||||
break;
|
||||
}
|
||||
const bool itemOk = item.operation == L'C'
|
||||
? copyWithRetry(stagingDir / relative, installDir / relative)
|
||||
: removeWithRetry(installDir / relative);
|
||||
if (!itemOk) {
|
||||
success = false;
|
||||
failedPath = relative;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!success) rolledBack = rollback(installDir, backupDir, paths);
|
||||
}
|
||||
|
||||
const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
|
||||
if (!success)
|
||||
rolledBack = rollback(installDir, backupDir, paths);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
return (success || rolledBack) ? 0 : 5;
|
||||
}
|
||||
|
||||
+45
-25
@@ -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")
|
||||
message(FATAL_ERROR
|
||||
"OpenSSL not found: ${SIMCAE_OPENSSL_ROOT}\n"
|
||||
"Copy OpenSSL-Win64 to thirdparty/OpenSSL-Win64, or configure with "
|
||||
"-DSIMCAE_OPENSSL_ROOT=<OpenSSL-Win64 root>."
|
||||
)
|
||||
endif()
|
||||
set(OPENSSL_INC "${SIMCAE_OPENSSL_ROOT}/include")
|
||||
set(OPENSSL_LIB_DEBUG "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MDd")
|
||||
set(OPENSSL_LIB_RELEASE "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MD")
|
||||
foreach(OPENSSL_LIB_DIR IN ITEMS "${OPENSSL_LIB_DEBUG}" "${OPENSSL_LIB_RELEASE}")
|
||||
if(NOT EXISTS "${OPENSSL_LIB_DIR}")
|
||||
message(FATAL_ERROR "OpenSSL library directory not found: ${OPENSSL_LIB_DIR}")
|
||||
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()
|
||||
endforeach()
|
||||
set(OPENSSL_INC "${SIMCAE_OPENSSL_ROOT}/include")
|
||||
set(OPENSSL_LIB_DEBUG "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MDd")
|
||||
set(OPENSSL_LIB_RELEASE "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MD")
|
||||
foreach(OPENSSL_LIB_DIR IN ITEMS "${OPENSSL_LIB_DEBUG}" "${OPENSSL_LIB_RELEASE}")
|
||||
if(NOT EXISTS "${OPENSSL_LIB_DIR}")
|
||||
message(FATAL_ERROR "OpenSSL library directory not found: ${OPENSSL_LIB_DIR}")
|
||||
endif()
|
||||
endforeach()
|
||||
set(SIMCAE_OPENSSL_LINK_LIBRARIES
|
||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libssl.lib>
|
||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libcrypto.lib>
|
||||
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libssl.lib>
|
||||
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libcrypto.lib>
|
||||
)
|
||||
else()
|
||||
find_package(OpenSSL REQUIRED)
|
||||
set(OPENSSL_INC "${OPENSSL_INCLUDE_DIR}")
|
||||
set(SIMCAE_OPENSSL_LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto)
|
||||
endif()
|
||||
|
||||
add_compile_definitions(HAVE_OPENSSL=1)
|
||||
|
||||
# 统一输出目录
|
||||
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)
|
||||
# 统一输出目录。Windows 保持历史 out/bin;Linux 单独输出,避免和 Windows DLL/PDB 混在一起。
|
||||
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)
|
||||
|
||||
# 先编译公共库
|
||||
@@ -99,7 +114,11 @@ 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")
|
||||
if(UNIX AND NOT APPLE AND EXISTS "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json")
|
||||
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.linux.example.json")
|
||||
else()
|
||||
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
|
||||
endif()
|
||||
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
|
||||
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
|
||||
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
|
||||
@@ -130,6 +149,7 @@ 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}")
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+96
-10
@@ -284,6 +284,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();
|
||||
@@ -486,11 +506,30 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
|
||||
if (previousHash == sourceHash)
|
||||
return true;
|
||||
|
||||
const QJsonObject config = document.object();
|
||||
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();
|
||||
@@ -517,7 +556,8 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
|
||||
const QString configDir = QFileInfo(m_configPath).absolutePath();
|
||||
const QStringList staleFiles{
|
||||
QDir(configDir).filePath(QStringLiteral("client_identity.dat")),
|
||||
QDir(configDir).filePath(QStringLiteral("version_policy.dat"))
|
||||
QDir(configDir).filePath(QStringLiteral("version_policy.dat")),
|
||||
QDir(configDir).filePath(QStringLiteral("local_state.json"))
|
||||
};
|
||||
for (const QString& staleFile : staleFiles)
|
||||
{
|
||||
@@ -529,8 +569,48 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
|
||||
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 (!ConfigHelper::writeFileWithElevationIfNeeded(m_configPath, emptyFileBytes, &writeError))
|
||||
{
|
||||
m_error = QStringLiteral("Cannot clear app_config.json after registry import: %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;
|
||||
}
|
||||
|
||||
@@ -631,13 +711,19 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||
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", "health_check_timeout_ms", "15000");
|
||||
config.insert("platform", "windows");
|
||||
config.insert("arch", "x64");
|
||||
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");
|
||||
#ifdef Q_OS_WIN
|
||||
config.insert("platform", "windows");
|
||||
#elif defined(Q_OS_LINUX)
|
||||
config.insert("platform", "linux");
|
||||
#else
|
||||
config.insert("platform", "unknown");
|
||||
#endif
|
||||
config.insert("arch", "x64");
|
||||
|
||||
QDir().mkpath(QFileInfo(m_configPath).path());
|
||||
QSaveFile output(m_configPath);
|
||||
|
||||
@@ -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);
|
||||
@@ -26,6 +28,7 @@ private:
|
||||
bool migrateLegacyIniIfNeeded();
|
||||
void enterRegistryGroup(QSettings& settings) const;
|
||||
bool syncRegistryFromConfigFileIfChanged();
|
||||
bool sanitizeConfigFileAfterImport(QSettings& settings);
|
||||
bool readRegistryValue(const QString& key, QString* value) const;
|
||||
bool writeRegistryValue(const QString& key, const QString& value);
|
||||
QString readFileValue(const QString& key) const;
|
||||
|
||||
+74
-19
@@ -1,5 +1,20 @@
|
||||
#include "FileHelper.h"
|
||||
#include <QDebug>
|
||||
#include "FileHelper.h"
|
||||
#include <QDebug>
|
||||
#include <QFileInfo>
|
||||
#include <QThread>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString processImageName(const QString& exeName)
|
||||
{
|
||||
QString name = QFileInfo(exeName).fileName().trimmed();
|
||||
#ifndef Q_OS_WIN
|
||||
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
|
||||
name.chop(4);
|
||||
#endif
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
bool FileHelper::createDir(const QString &path)
|
||||
{
|
||||
@@ -22,21 +37,61 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
||||
return QFile::copy(src, dst);
|
||||
}
|
||||
|
||||
bool FileHelper::isProcessRunning(const QString &exeName)
|
||||
{
|
||||
QProcess process;
|
||||
process.start("tasklist");
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput();
|
||||
return output.contains(exeName, Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
bool FileHelper::killProcess(const QString &exeName)
|
||||
{
|
||||
if (!isProcessRunning(exeName))
|
||||
return true;
|
||||
QProcess process;
|
||||
process.start("taskkill /f /im " + exeName);
|
||||
process.waitForFinished(1000);
|
||||
return !isProcessRunning(exeName);
|
||||
bool FileHelper::isProcessRunning(const QString &exeName)
|
||||
{
|
||||
const QString imageName = processImageName(exeName);
|
||||
if (imageName.isEmpty())
|
||||
return false;
|
||||
|
||||
QProcess process;
|
||||
#ifdef Q_OS_WIN
|
||||
process.start(QStringLiteral("tasklist"), QStringList{
|
||||
QStringLiteral("/FI"),
|
||||
QStringLiteral("IMAGENAME eq %1").arg(imageName)
|
||||
});
|
||||
process.waitForFinished();
|
||||
const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
|
||||
return output.contains(imageName, Qt::CaseInsensitive);
|
||||
#elif defined(Q_OS_UNIX)
|
||||
process.start(QStringLiteral("pgrep"), QStringList{
|
||||
QStringLiteral("-x"),
|
||||
imageName
|
||||
});
|
||||
process.waitForFinished(1000);
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool FileHelper::killProcess(const QString &exeName)
|
||||
{
|
||||
if (!isProcessRunning(exeName))
|
||||
return true;
|
||||
const QString imageName = processImageName(exeName);
|
||||
if (imageName.isEmpty())
|
||||
return true;
|
||||
|
||||
QProcess process;
|
||||
#ifdef Q_OS_WIN
|
||||
process.start(QStringLiteral("taskkill"), QStringList{
|
||||
QStringLiteral("/f"),
|
||||
QStringLiteral("/im"),
|
||||
imageName
|
||||
});
|
||||
#elif defined(Q_OS_UNIX)
|
||||
process.start(QStringLiteral("pkill"), QStringList{
|
||||
QStringLiteral("-x"),
|
||||
imageName
|
||||
});
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
process.waitForFinished(3000);
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (!isProcessRunning(imageName))
|
||||
return true;
|
||||
QThread::msleep(100);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+11
-11
@@ -28,17 +28,17 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||
|
||||
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"
|
||||
};
|
||||
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
QSet<QString> protectedPaths{
|
||||
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"config/client_identity.dat", "config/version_policy.dat"
|
||||
};
|
||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||
if (!runtimePrefix.isEmpty()) {
|
||||
const QStringList runtimeProtected{
|
||||
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"config/client_identity.dat", "config/version_policy.dat"
|
||||
};
|
||||
for (const QString& protectedPath : runtimeProtected)
|
||||
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
||||
}
|
||||
|
||||
+28
-12
@@ -77,15 +77,31 @@ 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";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||
if (actual.isEmpty() || actual != expected) {
|
||||
if (errorMessage) *errorMessage = "ticket signature invalid; check launch_token";
|
||||
return false;
|
||||
}
|
||||
if (payload.value("app_id").toString() != expectedAppId) {
|
||||
if (errorMessage) *errorMessage = "ticket app_id mismatch";
|
||||
return false;
|
||||
}
|
||||
if (payload.value("device_id").toString() != expectedDeviceId) {
|
||||
if (errorMessage) *errorMessage = "ticket device_id mismatch";
|
||||
return false;
|
||||
}
|
||||
if (payload.value("version").toString() != expectedVersion) {
|
||||
if (errorMessage) *errorMessage = "ticket version mismatch";
|
||||
return false;
|
||||
}
|
||||
if (!timeOk) {
|
||||
if (errorMessage) *errorMessage = "ticket time invalid or expired";
|
||||
return false;
|
||||
}
|
||||
if (payload.value("nonce").toString().isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket nonce missing";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
+8
-5
@@ -9,7 +9,7 @@
|
||||
面向接入和部署,说明 SDK 是什么、怎么放到业务软件目录、app_config.json 怎么填、如何打包。
|
||||
|
||||
2. 第三方依赖说明.md
|
||||
面向编译环境,说明 Qt、OpenSSL、thirdparty/ 和 CMake 环境变量怎么配置。
|
||||
面向编译环境,说明 Windows/Linux 下 Qt、OpenSSL、thirdparty/ 和 CMake 环境变量怎么配置。
|
||||
|
||||
3. ../i18n/ReadMe.txt
|
||||
面向国际化维护,说明新增 tr() 文案后如何更新 .ts、生成 .qm,以及 .qrc 如何内嵌翻译资源。
|
||||
@@ -21,10 +21,13 @@
|
||||
客户端配置说明
|
||||
==============
|
||||
|
||||
config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前 Windows 用户的注册表,后续运行配置优先从注册表读取。
|
||||
config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp 启动时会把它同步到当前用户的 QSettings 配置区;Windows 下对应注册表,Linux 下对应用户配置文件。后续运行配置优先从 QSettings 读取。
|
||||
如果 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,避免继续使用旧授权身份或旧策略;目录不可写时会弹出管理员权限确认框。
|
||||
非空 app_config.json 成功导入注册表后会自动清空为 {},文件保留不删除,方便下次直接粘贴管理后台生成的新配置。
|
||||
Windows 注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。
|
||||
Linux 配置位置由 Qt QSettings 决定,通常在当前用户 home 目录的 .config/Marsco/UpdateClientSDK.conf 一类路径下。
|
||||
如果检测到 app_config.json 发生变化,SDK 会删除 config/client_identity.dat、config/version_policy.dat 和 config/local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;目录不可写时会弹出管理员权限确认框。
|
||||
正常启动成功后不要删除这些状态文件,它们用于本地身份、离线策略和安全状态。
|
||||
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
|
||||
|
||||
接入新软件时通常需要修改:
|
||||
@@ -35,7 +38,7 @@ config/app_config.json 是部署配置源文件。Launcher / Updater / MainApp
|
||||
4. launcher_executable、updater_executable、bootstrap_executable。
|
||||
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
|
||||
|
||||
完整格式参考 update-client/config/app_config.example.json。
|
||||
Windows 完整格式参考 update-client/config/app_config.example.json;Linux 完整格式参考 update-client/config/app_config.linux.example.json。
|
||||
运行时生成的 client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。app_config.json 可以作为部署模板,但不要把某台机器运行后产生的临时状态混进去。
|
||||
|
||||
Windows 发布打包:
|
||||
|
||||
+99
-2
@@ -66,6 +66,46 @@ dist/
|
||||
- `-IncludeDemoMainApp`:把仓库里的 Demo 主程序 `MainApp.exe` 也打进 SDK,方便演示。
|
||||
- `-IncludeQtRuntime`:把 Qt 运行库也打进 SDK。只有业务软件本身不带 Qt 时才建议使用。
|
||||
|
||||
Linux SDK 打包方式:
|
||||
|
||||
```bash
|
||||
cd /home/laluo/project/update-client
|
||||
|
||||
cmake --preset linux-x64-release
|
||||
cmake --build --preset linux-x64-release
|
||||
|
||||
bash ./scripts/package-sdk.sh \
|
||||
--source-dir ./out/linux/bin \
|
||||
--output-dir ./dist/UpdateClientSDK-linux \
|
||||
--archive ./dist/UpdateClientSDK-linux.tar.gz \
|
||||
--sdk-version 0.1.0
|
||||
```
|
||||
|
||||
Linux SDK 包里核心程序名不带 `.exe`:
|
||||
|
||||
```text
|
||||
dist/
|
||||
UpdateClientSDK-linux/
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||
sdk_manifest.json
|
||||
bin/
|
||||
Launcher
|
||||
Updater
|
||||
Bootstrap
|
||||
Common/
|
||||
ConfigHelper.h
|
||||
ConfigHelper.cpp
|
||||
TicketHelper.h
|
||||
TicketHelper.cpp
|
||||
config/
|
||||
app_config.json
|
||||
manifest_public_key.pem
|
||||
scripts/
|
||||
package-sdk.sh
|
||||
package-client.sh
|
||||
UpdateClientSDK-linux.tar.gz
|
||||
```
|
||||
|
||||
## 三、你:把 SDK 放进业务软件目录
|
||||
|
||||
假设业务软件目录是:
|
||||
@@ -145,8 +185,11 @@ D:\SimCAE_SDK\scripts\install-sdk.ps1 `
|
||||
- Launcher / Updater / MainApp 启动时会计算 `app_config.json` 解析后的 JSON 内容 SHA256;如果 JSON 内容和上次导入时不同,就把文件里的配置重新写入当前 Windows 用户的注册表。
|
||||
- 后续运行时优先从注册表读取配置,不再每次直接读 JSON。
|
||||
- 运行过程中产生的动态值,例如首次输入的 `license_key`、服务端返回的 `device_id`、升级后的 `current_version`,会写入注册表。
|
||||
- 为减少明文暴露,SDK 成功把非空 `app_config.json` 导入注册表后,会把 `app_config.json` 内容自动清空为 `{}`,但不会删除这个文件。以后你从管理后台复制新的客户端配置时,直接覆盖这个文件即可。
|
||||
- SDK 会同步更新注册表里的 `source_sha256`,所以 `{}` 不会在下一次启动时反向覆盖注册表配置。
|
||||
- 如果你手动修改了 `app_config.json`,下一次启动会重新导入并覆盖注册表里的同名字段。
|
||||
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除 `config/client_identity.dat` 和 `config/version_policy.dat`,避免继续使用旧 License、旧设备身份或旧版本策略;如果安装目录不可写,会弹出管理员权限确认框。
|
||||
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除 `config/client_identity.dat`、`config/version_policy.dat` 和 `config/local_state.json`,避免继续使用旧 License、旧设备身份、旧版本策略或旧防回滚状态;如果安装目录不可写,会弹出管理员权限确认框。
|
||||
- 正常启动成功后,不要删除 `client_identity.dat`、`version_policy.dat`、`local_state.json`。它们分别用于本地设备身份、离线策略和防回滚/防时间倒退,删除后会影响离线启动或导致重新授权。
|
||||
- 注册表按安装目录隔离;同一台电脑上多个安装目录不会互相覆盖配置。
|
||||
- 注册表位置为 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`。
|
||||
|
||||
@@ -217,6 +260,35 @@ main / WinMain 开头,创建主窗口之前
|
||||
|
||||
真正接入业务软件时,把这些启动检查逻辑移植到业务主程序。用户入口应改成 `Launcher.exe`,不要让用户直接双击 `SimCAE.exe`。
|
||||
|
||||
重要:业务主程序校验 ticket 时,必须使用 SDK 当前运行配置里的动态值,不要直接从 `app_config.json` 读取 `device_id`。
|
||||
|
||||
原因是 `app_config.json` 是部署源文件,网页生成时 `device_id` 通常为空;真正的设备 ID 是 `Launcher.exe` 首次向服务端登记后写入当前用户注册表的。`Launcher.exe` 生成 ticket 时使用的是注册表里的真实 `device_id`。如果业务主程序从 `app_config.json` 读取空的 `device_id` 来校验,就会出现:
|
||||
|
||||
```text
|
||||
ticket signature, identity, time or nonce invalid
|
||||
```
|
||||
|
||||
或者细化后的:
|
||||
|
||||
```text
|
||||
ticket device_id mismatch
|
||||
```
|
||||
|
||||
业务主程序应像 `MainApp/main.cpp` 示例一样使用:
|
||||
|
||||
```cpp
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
TicketHelper::consumeAndVerify(
|
||||
ticketFilePath,
|
||||
config.getValue("App", "app_id"),
|
||||
config.getValue("Update", "device_id"),
|
||||
config.getValue("App", "current_version"),
|
||||
config.getValue("App", "launch_token"),
|
||||
&ticketError);
|
||||
```
|
||||
|
||||
也就是说,接入业务主程序时不能只复制一小段 ticket 代码后自己解析 JSON;要么复用 SDK 的 `ConfigHelper` / `TicketHelper`,要么保证业务主程序读取到的 `app_id`、`device_id`、`current_version`、`launch_token` 和 `Launcher.exe` 完全来自同一套运行配置。
|
||||
|
||||
## 六、你:准备服务端数据
|
||||
|
||||
首次联调前,服务端至少要准备这些内容:
|
||||
@@ -286,6 +358,31 @@ dist/
|
||||
UpdateClient.zip
|
||||
```
|
||||
|
||||
Linux 最终客户端包生成方式:
|
||||
|
||||
```bash
|
||||
cd /home/laluo/project/update-client
|
||||
|
||||
bash ./scripts/package-client.sh \
|
||||
--source-dir /path/to/SimCAE \
|
||||
--config-file /path/to/SimCAE/bin/config/app_config.json \
|
||||
--output-dir ./dist/UpdateClient-linux \
|
||||
--archive ./dist/UpdateClient-linux.tar.gz
|
||||
```
|
||||
|
||||
Linux 打包脚本会检查:
|
||||
|
||||
- `app_config.json` 必填字段是否完整。
|
||||
- 主程序、Launcher、Updater、Bootstrap 是否存在。
|
||||
- 是否混入 Debug 产物。
|
||||
- 当前版本是否已有签名 Manifest 缓存。
|
||||
- 是否存在重复主程序。
|
||||
|
||||
Linux 下如果程序安装在 `/opt`、`/usr/local` 等普通用户不可写目录,升级器无法像 Windows UAC 那样自动提权修改安装目录。正式部署前建议二选一:
|
||||
|
||||
1. 把软件安装到当前用户有写权限的目录,例如用户 home 下的应用目录。
|
||||
2. 由安装器创建专用目录和权限,让运行用户对软件目录有写入权限。
|
||||
|
||||
## 九、常见错误
|
||||
|
||||
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
|
||||
@@ -295,5 +392,5 @@ dist/
|
||||
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` 的安装根目录。
|
||||
8. 发布失败提示主程序不在根目录:服务端 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 要和平台匹配。Windows 通常是 `bin/SimCAE.exe`;Linux 通常是 `bin/SimCAE`。
|
||||
9. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。
|
||||
|
||||
+56
-4
@@ -4,11 +4,15 @@
|
||||
|
||||
当前客户端构建依赖:
|
||||
|
||||
1. Qt 5.15.2 msvc2019_64
|
||||
2. OpenSSL-Win64
|
||||
1. Qt 5.15.2 或兼容的 Qt 5 版本
|
||||
2. OpenSSL
|
||||
|
||||
Windows 下推荐使用 Qt 5.15.2 msvc2019_64 和 OpenSSL-Win64;Linux 下使用系统安装的 Qt/OpenSSL 开发包。
|
||||
|
||||
## 1. Qt 配置
|
||||
|
||||
### Windows
|
||||
|
||||
Qt 路径由本机环境变量提供。你需要在 Windows 环境变量里配置 Qt 路径,让 CMake 的 `find_package(Qt5 ...)` 能找到 Qt。
|
||||
|
||||
推荐配置用户环境变量 `CMAKE_PREFIX_PATH`:
|
||||
@@ -35,8 +39,27 @@ $env:CMAKE_PREFIX_PATH="C:\Qt\5.15.2\msvc2019_64"
|
||||
|
||||
一般不需要把 `C:\Qt\5.15.2\msvc2019_64\bin` 加入 `Path`。项目构建后会通过 `windeployqt` 复制运行所需的 Qt DLL。
|
||||
|
||||
### Linux
|
||||
|
||||
Linux 下需要安装 Qt5 开发包,让 CMake 能找到 `Qt5::Core`、`Qt5::Network`、`Qt5::Gui`、`Qt5::Widgets`。
|
||||
|
||||
Ubuntu/Debian 示例:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev-tools libssl-dev
|
||||
```
|
||||
|
||||
如果 Qt 安装在自定义目录,可以临时设置:
|
||||
|
||||
```bash
|
||||
export CMAKE_PREFIX_PATH=/path/to/Qt/5.x/gcc_64
|
||||
```
|
||||
|
||||
## 2. OpenSSL 配置
|
||||
|
||||
### Windows
|
||||
|
||||
OpenSSL 默认放在:
|
||||
|
||||
```text
|
||||
@@ -74,7 +97,11 @@ cmake -S . -B out\build\x64-Debug `
|
||||
-DSIMCAE_OPENSSL_ROOT="C:\Program Files\OpenSSL-Win64"
|
||||
```
|
||||
|
||||
## 3. 重新配置和编译
|
||||
### Linux
|
||||
|
||||
Linux 下 CMake 会通过 `find_package(OpenSSL REQUIRED)` 查找系统 OpenSSL。通常安装 `libssl-dev` 即可,不需要 `thirdparty/OpenSSL-Win64`。
|
||||
|
||||
## 3. Windows 重新配置和编译
|
||||
|
||||
如果之前配置过 CMake,建议先删除旧缓存:
|
||||
|
||||
@@ -97,7 +124,32 @@ cmake -S . -B out\build\x64-Debug `
|
||||
cmake --build out\build\x64-Debug --config Debug
|
||||
```
|
||||
|
||||
## 4. 提交注意事项
|
||||
## 4. Linux 重新配置和编译
|
||||
|
||||
如果之前配置过 CMake,建议先删除旧缓存:
|
||||
|
||||
```bash
|
||||
cd ~/project/update-client
|
||||
rm -rf out/build/linux-x64-debug out/build/linux-x64-release
|
||||
```
|
||||
|
||||
Debug 构建:
|
||||
|
||||
```bash
|
||||
cmake --preset linux-x64-debug
|
||||
cmake --build --preset linux-x64-debug
|
||||
```
|
||||
|
||||
Release 构建:
|
||||
|
||||
```bash
|
||||
cmake --preset linux-x64-release
|
||||
cmake --build --preset linux-x64-release
|
||||
```
|
||||
|
||||
Linux 构建时会自动使用 `config/app_config.linux.example.json` 作为输出目录里的默认 `config/app_config.json`,可执行程序名不带 `.exe`。
|
||||
|
||||
## 5. 提交注意事项
|
||||
|
||||
不要提交下面这些内容:
|
||||
|
||||
|
||||
@@ -27,12 +27,8 @@ 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)
|
||||
# 强制VS打开CMake文件夹时默认选中该可执行目标
|
||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
|
||||
+73
-73
@@ -1,12 +1,12 @@
|
||||
#include "UpdateLogic.h"
|
||||
#include "UpdateLogic.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QDebug>
|
||||
#include <QJsonArray>
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include <QSaveFile>
|
||||
#include "PolicyHelper.h"
|
||||
#include "LocalStateHelper.h"
|
||||
#include <QJsonArray>
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
#include <QSaveFile>
|
||||
#include "PolicyHelper.h"
|
||||
#include "LocalStateHelper.h"
|
||||
|
||||
UpdateLogic::UpdateLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
@@ -83,8 +83,8 @@ void UpdateLogic::checkUpdate()
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
|
||||
{
|
||||
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
|
||||
{
|
||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
@@ -99,69 +99,69 @@ void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, c
|
||||
qDebug() << "\n========== File Download Url ==========";
|
||||
qDebug() << resp["files"].toArray();
|
||||
});
|
||||
}
|
||||
|
||||
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
|
||||
int versionId, const QString& cacheDir)
|
||||
{
|
||||
m_error.clear();
|
||||
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
|
||||
m_error = "manifest identity is incomplete";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject response;
|
||||
int statusCode = 0;
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
body["channel"] = channel;
|
||||
body["version"] = version;
|
||||
body["version_id"] = versionId;
|
||||
m_http.postRequest(m_serverAddr + "/api/v1/update/manifest", body,
|
||||
[&](int code, const QJsonObject& resp) {
|
||||
statusCode = code;
|
||||
response = resp;
|
||||
});
|
||||
|
||||
if (statusCode != 200) {
|
||||
m_error = QString("manifest request failed (HTTP %1)").arg(statusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject manifest = response.value("manifest").toObject();
|
||||
const QString manifestText = response.value("manifest_text").toString();
|
||||
if (manifest.isEmpty() || manifestText.isEmpty()) {
|
||||
m_error = "manifest response is incomplete";
|
||||
return false;
|
||||
}
|
||||
if (manifest.value("app_id").toString() != appId
|
||||
|| manifest.value("channel").toString() != channel
|
||||
|| manifest.value("version").toString() != version) {
|
||||
m_error = "manifest identity does not match current version";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir dir(cacheDir);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
m_error = "cannot create manifest cache directory";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject wrapper;
|
||||
wrapper["manifest"] = manifest;
|
||||
wrapper["manifest_text"] = manifestText;
|
||||
QSaveFile file(dir.filePath("manifest_" + version + ".json"));
|
||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||
m_error = "cannot save signed manifest cache";
|
||||
return false;
|
||||
}
|
||||
qDebug() << "Current version manifest cached to" << file.fileName();
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
|
||||
{
|
||||
}
|
||||
|
||||
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
|
||||
int versionId, const QString& cacheDir)
|
||||
{
|
||||
m_error.clear();
|
||||
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty() || versionId <= 0) {
|
||||
m_error = "manifest identity is incomplete";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject response;
|
||||
int statusCode = 0;
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
body["channel"] = channel;
|
||||
body["version"] = version;
|
||||
body["version_id"] = versionId;
|
||||
m_http.postRequest(m_serverAddr + "/api/v1/update/manifest", body,
|
||||
[&](int code, const QJsonObject& resp) {
|
||||
statusCode = code;
|
||||
response = resp;
|
||||
});
|
||||
|
||||
if (statusCode != 200) {
|
||||
m_error = QString("manifest request failed (HTTP %1)").arg(statusCode);
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject manifest = response.value("manifest").toObject();
|
||||
const QString manifestText = response.value("manifest_text").toString();
|
||||
if (manifest.isEmpty() || manifestText.isEmpty()) {
|
||||
m_error = "manifest response is incomplete";
|
||||
return false;
|
||||
}
|
||||
if (manifest.value("app_id").toString() != appId
|
||||
|| manifest.value("channel").toString() != channel
|
||||
|| manifest.value("version").toString() != version) {
|
||||
m_error = "manifest identity does not match current version";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir dir(cacheDir);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
m_error = "cannot create manifest cache directory";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject wrapper;
|
||||
wrapper["manifest"] = manifest;
|
||||
wrapper["manifest_text"] = manifestText;
|
||||
QSaveFile file(dir.filePath("manifest_" + version + ".json"));
|
||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||
m_error = "cannot save signed manifest cache";
|
||||
return false;
|
||||
}
|
||||
qDebug() << "Current version manifest cached to" << file.fileName();
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
|
||||
{
|
||||
QString url = m_serverAddr + "/api/v1/update/report";
|
||||
QJsonObject body;
|
||||
body["app_id"] = m_appId;
|
||||
@@ -175,4 +175,4 @@ void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fro
|
||||
qDebug() << "\n========== Update Report Result ==========";
|
||||
qDebug() << resp;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -162,11 +162,11 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||
const QString value = config.getValue("Runtime", key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
};
|
||||
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
|
||||
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
|
||||
return ConfigHelper::executableNameForCurrentPlatform(
|
||||
config.getValue("Runtime", key), fallback);
|
||||
};
|
||||
const QString mainExecutable = configuredName("main_executable", "MainApp");
|
||||
const QString updaterExecutable = configuredName("updater_executable", "Updater");
|
||||
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
|
||||
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
|
||||
const auto importOfflinePackage = [&]() {
|
||||
|
||||
+3
-3
@@ -26,9 +26,9 @@ 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";
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
|
||||
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, "Launcher");
|
||||
QString ticketFilePath;
|
||||
QString healthFilePath;
|
||||
for (int i = 1; i < argc; ++i)
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-20
@@ -264,23 +264,28 @@ 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"),
|
||||
QSet<QString> protectedPaths{
|
||||
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")
|
||||
};
|
||||
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")
|
||||
const QStringList runtimeProtected{
|
||||
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")
|
||||
};
|
||||
for (const QString& path : runtimeProtected)
|
||||
protectedPaths.insert(runtimePrefix + "/" + path);
|
||||
@@ -562,21 +567,22 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
||||
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"),
|
||||
QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap"),
|
||||
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")
|
||||
};
|
||||
const QStringList runtimeProtected{
|
||||
QStringLiteral("bootstrap"), 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);
|
||||
}
|
||||
|
||||
+7
-7
@@ -172,13 +172,13 @@ 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 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;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"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",
|
||||
"launcher_executable": "Launcher",
|
||||
"updater_executable": "Updater",
|
||||
"bootstrap_executable": "Bootstrap",
|
||||
"health_check_timeout_ms": "15000",
|
||||
"platform": "linux",
|
||||
"arch": "x64"
|
||||
}
|
||||
Binary file not shown.
@@ -871,4 +871,34 @@ The required space includes downloaded files, old version backups, and a safety
|
||||
<translation>离线包文件数量与 Manifest 不一致</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>Bootstrap</name>
|
||||
<message>
|
||||
<location filename="../Bootstrap/main.cpp" line="22" />
|
||||
<source>Update Handoff Failed</source>
|
||||
<translation>更新交接失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../Bootstrap/main.cpp" line="137" />
|
||||
<source>Bootstrap arguments are incomplete.</source>
|
||||
<translation>Bootstrap 启动参数不完整。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../Bootstrap/main.cpp" line="154" />
|
||||
<source>The update plan is missing or invalid.</source>
|
||||
<translation>更新计划文件缺失或格式无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../Bootstrap/main.cpp" line="189" />
|
||||
<source>Cannot restart Updater.</source>
|
||||
<translation>无法重新启动 Updater。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<location filename="../Bootstrap/main.cpp" line="191" />
|
||||
<source>
|
||||
Failed file: %1</source>
|
||||
<translation>
|
||||
失败文件:%1</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
|
||||
+26
-2
@@ -6,14 +6,20 @@
|
||||
脚本列表:
|
||||
|
||||
1. package-sdk.ps1
|
||||
生成给其他软件接入用的 UpdateClientSDK 包。
|
||||
在 Windows 上生成给其他软件接入用的 UpdateClientSDK 包。
|
||||
|
||||
2. package-client.ps1
|
||||
生成某个具体产品的最终客户端发布包。
|
||||
在 Windows 上生成某个具体产品的最终客户端发布包。
|
||||
|
||||
3. install-sdk.ps1
|
||||
将 SDK 运行时复制到业务软件 Release 目录。
|
||||
|
||||
4. package-sdk.sh
|
||||
在 Linux 上生成给其他软件接入用的 UpdateClientSDK 包,输出 tar.gz。
|
||||
|
||||
5. package-client.sh
|
||||
在 Linux 上生成某个具体产品的最终客户端发布包,输出 tar.gz。
|
||||
|
||||
推荐在 update-client 根目录执行:
|
||||
|
||||
```powershell
|
||||
@@ -31,3 +37,21 @@
|
||||
-OutputDir .\dist\UpdateClient `
|
||||
-ZipFile .\dist\UpdateClient.zip
|
||||
```
|
||||
|
||||
Linux 示例:
|
||||
|
||||
```bash
|
||||
bash ./scripts/package-sdk.sh \
|
||||
--source-dir ./out/linux/bin \
|
||||
--output-dir ./dist/UpdateClientSDK-linux \
|
||||
--archive ./dist/UpdateClientSDK-linux.tar.gz \
|
||||
--sdk-version 0.1.0
|
||||
```
|
||||
|
||||
```bash
|
||||
bash ./scripts/package-client.sh \
|
||||
--source-dir /path/to/SimCAE \
|
||||
--config-file /path/to/SimCAE/bin/config/app_config.json \
|
||||
--output-dir ./dist/UpdateClient-linux \
|
||||
--archive ./dist/UpdateClient-linux.tar.gz
|
||||
```
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
|
||||
CONFIG_FILE=""
|
||||
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClient-linux"
|
||||
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClient-linux.tar.gz"
|
||||
SKIP_MANIFEST_CHECK=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: package-client.sh --config-file FILE [options]
|
||||
|
||||
Options:
|
||||
--source-dir DIR Release/install root to package. Default: ./out/linux/bin
|
||||
--config-file FILE app_config.json used by this client package. Required.
|
||||
--output-dir DIR Output directory. Default: ./dist/UpdateClient-linux
|
||||
--archive FILE Output tar.gz. Default: ./dist/UpdateClient-linux.tar.gz
|
||||
--skip-manifest-check Skip current-version manifest cache check.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
normalize_relative_path() {
|
||||
local value="${1:-}"
|
||||
value="${value//\\//}"
|
||||
value="${value#/}"
|
||||
value="${value%/}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
join_relative_path() {
|
||||
local base
|
||||
local child
|
||||
base="$(normalize_relative_path "${1:-}")"
|
||||
child="$(normalize_relative_path "${2:-}")"
|
||||
if [[ -z "$base" ]]; then printf '%s' "$child"; return; fi
|
||||
if [[ -z "$child" ]]; then printf '%s' "$base"; return; fi
|
||||
printf '%s/%s' "$base" "$child"
|
||||
}
|
||||
|
||||
json_value() {
|
||||
python3 - "$CONFIG_FILE" "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
value = data.get(sys.argv[2], "")
|
||||
print("" if value is None else value)
|
||||
PY
|
||||
}
|
||||
|
||||
relative_to_source() {
|
||||
local full="$1"
|
||||
local fallback="$2"
|
||||
python3 - "$SOURCE_DIR" "$full" "$fallback" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
source = os.path.realpath(sys.argv[1])
|
||||
full = os.path.realpath(sys.argv[2])
|
||||
fallback = sys.argv[3]
|
||||
try:
|
||||
rel = os.path.relpath(full, source)
|
||||
except ValueError:
|
||||
rel = fallback
|
||||
if rel.startswith(".."):
|
||||
rel = fallback
|
||||
print(rel.replace(os.sep, "/").strip("/"))
|
||||
PY
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
|
||||
--config-file) CONFIG_FILE="$2"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
|
||||
--skip-manifest-check) SKIP_MANIFEST_CHECK=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$CONFIG_FILE" ]]; then
|
||||
echo "--config-file is required." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
|
||||
CONFIG_FILE="$(realpath "$CONFIG_FILE")"
|
||||
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
|
||||
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
|
||||
|
||||
for field in app_id channel api_base_url current_version client_token launch_token license_key main_executable launcher_executable updater_executable bootstrap_executable; do
|
||||
if [[ -z "$(json_value "$field")" ]]; then
|
||||
echo "Config file is missing required field: $field" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
CONFIG_RELATIVE_PATH="$(relative_to_source "$CONFIG_FILE" "config/app_config.json")"
|
||||
CONFIG_RELATIVE_PARENT="$(dirname "$CONFIG_RELATIVE_PATH")"
|
||||
[[ "$CONFIG_RELATIVE_PARENT" == "." ]] && CONFIG_RELATIVE_PARENT=""
|
||||
RUNTIME_DIR_RELATIVE="$(normalize_relative_path "$(dirname "$CONFIG_RELATIVE_PARENT")")"
|
||||
[[ "$RUNTIME_DIR_RELATIVE" == "." ]] && RUNTIME_DIR_RELATIVE=""
|
||||
|
||||
MAIN_EXECUTABLE="$(normalize_relative_path "$(json_value main_executable)")"
|
||||
LAUNCHER_EXECUTABLE="$(normalize_relative_path "$(json_value launcher_executable)")"
|
||||
UPDATER_EXECUTABLE="$(normalize_relative_path "$(json_value updater_executable)")"
|
||||
BOOTSTRAP_EXECUTABLE="$(normalize_relative_path "$(json_value bootstrap_executable)")"
|
||||
CURRENT_VERSION="$(json_value current_version)"
|
||||
|
||||
for required in \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")"; do
|
||||
if [[ ! -f "$SOURCE_DIR/$required" ]]; then
|
||||
echo "Source directory is missing required file: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
|
||||
if [[ -n "$DEBUG_ARTIFACT" ]]; then
|
||||
echo "Source directory contains Debug artifacts. Use a clean Release root directory." >&2
|
||||
echo "Example: $DEBUG_ARTIFACT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_MAIN_PATH="$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")"
|
||||
MAIN_LEAF="$(basename "$MAIN_EXECUTABLE")"
|
||||
DUPLICATE_MAIN="$({ find "$SOURCE_DIR" -type f -name "$MAIN_LEAF" | while read -r item; do
|
||||
rel="$(python3 - "$SOURCE_DIR" "$item" <<'PY'
|
||||
import os, sys
|
||||
print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])).replace(os.sep, "/"))
|
||||
PY
|
||||
)"
|
||||
[[ "$rel" != "$EXPECTED_MAIN_PATH" ]] && { echo "$item"; break; }
|
||||
done; } || true)"
|
||||
if [[ -n "$DUPLICATE_MAIN" ]]; then
|
||||
echo "Source directory contains a duplicate main executable outside $EXPECTED_MAIN_PATH: $DUPLICATE_MAIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST_NAME="manifest_${CURRENT_VERSION}.json"
|
||||
SOURCE_MANIFEST="$SOURCE_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache/$MANIFEST_NAME")"
|
||||
if [[ ! -f "$SOURCE_MANIFEST" && -f "$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME" ]]; then
|
||||
SOURCE_MANIFEST="$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME"
|
||||
fi
|
||||
if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then
|
||||
echo "Missing signed Manifest cache for current version: $SOURCE_MANIFEST. Complete online update/verification for this version before packaging." >&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"
|
||||
+27
-10
@@ -56,10 +56,11 @@ if ($debugArtifacts) {
|
||||
if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force }
|
||||
New-Item $OutputDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$binDir = Join-Path $OutputDir "bin"
|
||||
$configDir = Join-Path $OutputDir "config"
|
||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
|
||||
$binDir = Join-Path $OutputDir "bin"
|
||||
$configDir = Join-Path $OutputDir "config"
|
||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||
$commonDir = Join-Path $OutputDir "Common"
|
||||
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" }
|
||||
@@ -97,8 +98,23 @@ 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
|
||||
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
||||
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
|
||||
|
||||
$commonSourceDir = Join-Path $RepoRoot "Common"
|
||||
$commonSourceFiles = @(
|
||||
"ConfigHelper.h",
|
||||
"ConfigHelper.cpp",
|
||||
"TicketHelper.h",
|
||||
"TicketHelper.cpp"
|
||||
)
|
||||
foreach ($commonFile in $commonSourceFiles) {
|
||||
$commonPath = Join-Path $commonSourceDir $commonFile
|
||||
if (-not (Test-Path $commonPath)) {
|
||||
throw "SDK Common integration source is missing: $commonPath"
|
||||
}
|
||||
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
|
||||
}
|
||||
|
||||
$wordGuideSource = @($RepoRoot, (Join-Path $RepoRoot "Docs")) |
|
||||
Where-Object { Test-Path $_ } |
|
||||
@@ -118,10 +134,11 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in
|
||||
@{
|
||||
sdk_version = $SdkVersion
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
sdk_type = "external-updater-runtime"
|
||||
required_entry = "Launcher.exe"
|
||||
contains_demo_main_app = [bool]$IncludeDemoMainApp
|
||||
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
|
||||
sdk_type = "external-updater-runtime"
|
||||
required_entry = "Launcher.exe"
|
||||
contains_demo_main_app = [bool]$IncludeDemoMainApp
|
||||
integration_sources = $commonSourceFiles
|
||||
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
|
||||
|
||||
$zipParent = Split-Path $ZipFile -Parent
|
||||
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
|
||||
OUTPUT_DIR="$REPO_ROOT/dist/UpdateClientSDK-linux"
|
||||
ARCHIVE_FILE="$REPO_ROOT/dist/UpdateClientSDK-linux.tar.gz"
|
||||
SDK_VERSION="0.1.0"
|
||||
EXAMPLE_CONFIG="$REPO_ROOT/config/app_config.linux.example.json"
|
||||
INCLUDE_DEMO_MAIN_APP=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: package-sdk.sh [options]
|
||||
|
||||
Options:
|
||||
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
|
||||
--output-dir DIR SDK directory to generate. Default: ./dist/UpdateClientSDK-linux
|
||||
--archive FILE SDK tar.gz path. Default: ./dist/UpdateClientSDK-linux.tar.gz
|
||||
--sdk-version VERSION SDK version. Default: 0.1.0
|
||||
--example-config FILE app_config template. Default: ./config/app_config.linux.example.json
|
||||
--include-demo-mainapp Include MainApp demo executable in SDK bin.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
|
||||
--sdk-version) SDK_VERSION="$2"; shift 2 ;;
|
||||
--example-config) EXAMPLE_CONFIG="$2"; shift 2 ;;
|
||||
--include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
|
||||
EXAMPLE_CONFIG="$(realpath "$EXAMPLE_CONFIG")"
|
||||
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
|
||||
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
|
||||
|
||||
for name in Launcher Updater Bootstrap; do
|
||||
if [[ ! -f "$SOURCE_DIR/$name" ]]; then
|
||||
echo "SDK source directory is missing required file: $SOURCE_DIR/$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
PUBLIC_KEY=""
|
||||
for candidate in \
|
||||
"$SOURCE_DIR/config/manifest_public_key.pem" \
|
||||
"$SOURCE_DIR/manifest_public_key.pem" \
|
||||
"$REPO_ROOT/config/manifest_public_key.pem"; do
|
||||
if [[ -f "$candidate" ]]; then
|
||||
PUBLIC_KEY="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$PUBLIC_KEY" ]]; then
|
||||
echo "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
|
||||
if [[ -n "$DEBUG_ARTIFACT" ]]; then
|
||||
echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2
|
||||
echo "Example: $DEBUG_ARTIFACT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common"
|
||||
|
||||
shopt -s dotglob nullglob
|
||||
for item in "$SOURCE_DIR"/*; do
|
||||
base="$(basename "$item")"
|
||||
case "$base" in
|
||||
config|update|update_temp|manifest_public_key.pem|MainApp)
|
||||
if [[ "$base" == "MainApp" && "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]]; then
|
||||
cp -a "$item" "$OUTPUT_DIR/bin/"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
cp -a "$item" "$OUTPUT_DIR/bin/"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shopt -u dotglob nullglob
|
||||
|
||||
cp "$EXAMPLE_CONFIG" "$OUTPUT_DIR/config/app_config.json"
|
||||
cp "$PUBLIC_KEY" "$OUTPUT_DIR/config/manifest_public_key.pem"
|
||||
|
||||
for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.cpp; do
|
||||
common_path="$REPO_ROOT/Common/$common_file"
|
||||
if [[ ! -f "$common_path" ]]; then
|
||||
echo "SDK Common integration source is missing: $common_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
|
||||
done
|
||||
|
||||
WORD_GUIDE="$(find "$REPO_ROOT" "$REPO_ROOT/Docs" -maxdepth 1 -type f -name '*SDK*.docx' ! -name '~$*' 2>/dev/null | sort | sed -n '1p')"
|
||||
if [[ -z "$WORD_GUIDE" ]]; then
|
||||
echo "SDK integration Word guide is missing. Expected a *SDK*.docx file in the repository root or Docs directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$WORD_GUIDE" "$OUTPUT_DIR/$(basename "$WORD_GUIDE")"
|
||||
|
||||
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),
|
||||
"integration_sources": [
|
||||
"ConfigHelper.h",
|
||||
"ConfigHelper.cpp",
|
||||
"TicketHelper.h",
|
||||
"TicketHelper.cpp"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p "$(dirname "$ARCHIVE_FILE")"
|
||||
rm -f "$ARCHIVE_FILE"
|
||||
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
|
||||
|
||||
echo "SDK directory: $OUTPUT_DIR"
|
||||
echo "SDK archive: $ARCHIVE_FILE"
|
||||
echo "SDK version: $SDK_VERSION"
|
||||
Reference in New Issue
Block a user