diff --git a/Bootstrap/CMakeLists.txt b/Bootstrap/CMakeLists.txt index 7df2f5a..b30cd31 100644 --- a/Bootstrap/CMakeLists.txt +++ b/Bootstrap/CMakeLists.txt @@ -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} $,--debug,--release> $ + COMMENT "Deploy Qt runtime for Bootstrap" + ) +endif() diff --git a/Bootstrap/main.cpp b/Bootstrap/main.cpp index a1739c9..97f9a7a 100644 --- a/Bootstrap/main.cpp +++ b/Bootstrap/main.cpp @@ -1,191 +1,200 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -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(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(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& 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& 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 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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& 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* items, QVector* 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(_wtoi(argv[6])); - std::vector 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 items; - std::vector 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 items; + QVector 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(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; +} diff --git a/CMakeLists.txt b/CMakeLists.txt index 012a495..14f847e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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= +# 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=." - ) -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=." + ) 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 + $<$:${OPENSSL_LIB_DEBUG}/libssl.lib> + $<$:${OPENSSL_LIB_DEBUG}/libcrypto.lib> + $<$>:${OPENSSL_LIB_RELEASE}/libssl.lib> + $<$>:${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}") diff --git a/CMakePresets.json b/CMakePresets.json index 8d77981..78fb556 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -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" } ] } diff --git a/Common/CMakeLists.txt b/Common/CMakeLists.txt index 6e76e53..aaf3e1a 100644 --- a/Common/CMakeLists.txt +++ b/Common/CMakeLists.txt @@ -26,14 +26,9 @@ target_include_directories(Common PRIVATE ${OPENSSL_INC} ) -# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe -target_link_directories(Common INTERFACE - $<$:${OPENSSL_LIB_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) diff --git a/Common/ConfigHelper.cpp b/Common/ConfigHelper.cpp index e01d3f1..f037a2c 100644 --- a/Common/ConfigHelper.cpp +++ b/Common/ConfigHelper.cpp @@ -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); diff --git a/Common/ConfigHelper.h b/Common/ConfigHelper.h index 35d1025..be1cca7 100644 --- a/Common/ConfigHelper.h +++ b/Common/ConfigHelper.h @@ -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; diff --git a/Common/FileHelper.cpp b/Common/FileHelper.cpp index 5f4526e..254329b 100644 --- a/Common/FileHelper.cpp +++ b/Common/FileHelper.cpp @@ -1,5 +1,20 @@ -#include "FileHelper.h" -#include +#include "FileHelper.h" +#include +#include +#include + +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; } diff --git a/Common/IntegrityHelper.cpp b/Common/IntegrityHelper.cpp index efd5d75..30f9f9a 100644 --- a/Common/IntegrityHelper.cpp +++ b/Common/IntegrityHelper.cpp @@ -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 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 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); } diff --git a/Common/TicketHelper.cpp b/Common/TicketHelper.cpp index 5222536..1de50ec 100644 --- a/Common/TicketHelper.cpp +++ b/Common/TicketHelper.cpp @@ -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; +} diff --git a/Docs/ReadMe.txt b/Docs/ReadMe.txt index d2d5a2f..693537a 100644 --- a/Docs/ReadMe.txt +++ b/Docs/ReadMe.txt @@ -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 发布打包: diff --git a/Docs/客户端部署说明.md b/Docs/客户端部署说明.md index 4482b29..2c370e6 100644 --- a/Docs/客户端部署说明.md +++ b/Docs/客户端部署说明.md @@ -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`。 diff --git a/Docs/第三方依赖说明.md b/Docs/第三方依赖说明.md index 124603c..0be4442 100644 --- a/Docs/第三方依赖说明.md +++ b/Docs/第三方依赖说明.md @@ -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. 提交注意事项 不要提交下面这些内容: diff --git a/Launcher/CMakeLists.txt b/Launcher/CMakeLists.txt index b9b31c8..574b66b 100644 --- a/Launcher/CMakeLists.txt +++ b/Launcher/CMakeLists.txt @@ -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) diff --git a/Launcher/UpdateLogic.cpp b/Launcher/UpdateLogic.cpp index b3ae6ae..61c553d 100644 --- a/Launcher/UpdateLogic.cpp +++ b/Launcher/UpdateLogic.cpp @@ -1,12 +1,12 @@ -#include "UpdateLogic.h" +#include "UpdateLogic.h" #include "ConfigHelper.h" #include -#include -#include -#include -#include -#include "PolicyHelper.h" -#include "LocalStateHelper.h" +#include +#include +#include +#include +#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; }); -} +} diff --git a/Launcher/main.cpp b/Launcher/main.cpp index 9b2a99e..6f23f3c 100644 --- a/Launcher/main.cpp +++ b/Launcher/main.cpp @@ -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 = [&]() { diff --git a/MainApp/main.cpp b/MainApp/main.cpp index e2a511a..19561d6 100644 --- a/MainApp/main.cpp +++ b/MainApp/main.cpp @@ -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) diff --git a/Updater/CMakeLists.txt b/Updater/CMakeLists.txt index 0e77f5a..365e4fa 100644 --- a/Updater/CMakeLists.txt +++ b/Updater/CMakeLists.txt @@ -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 diff --git a/Updater/UpdaterLogic.cpp b/Updater/UpdaterLogic.cpp index 58091ee..d0bac86 100644 --- a/Updater/UpdaterLogic.cpp +++ b/Updater/UpdaterLogic.cpp @@ -264,23 +264,28 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded()); } - QSet protectedPaths{ - QStringLiteral("bootstrap.exe"), - QStringLiteral("launcher.exe"), - QStringLiteral("updater.exe"), - QStringLiteral("client.ini"), - QStringLiteral("config/app_config.json"), + QSet 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 protectedPaths{ - QStringLiteral("bootstrap.exe"), - QStringLiteral("client.ini"), - QStringLiteral("config/app_config.json"), + QSet 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); } diff --git a/Updater/main.cpp b/Updater/main.cpp index 41e1daf..ab6f2af 100644 --- a/Updater/main.cpp +++ b/Updater/main.cpp @@ -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; diff --git a/config/app_config.linux.example.json b/config/app_config.linux.example.json new file mode 100644 index 0000000..31f3b0d --- /dev/null +++ b/config/app_config.linux.example.json @@ -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" +} diff --git a/i18n/update-client_zh_CN.qm b/i18n/update-client_zh_CN.qm index e24568e..cdbfb39 100644 Binary files a/i18n/update-client_zh_CN.qm and b/i18n/update-client_zh_CN.qm differ diff --git a/i18n/update-client_zh_CN.ts b/i18n/update-client_zh_CN.ts index 7ebdcb7..078c311 100644 --- a/i18n/update-client_zh_CN.ts +++ b/i18n/update-client_zh_CN.ts @@ -871,4 +871,34 @@ The required space includes downloaded files, old version backups, and a safety 离线包文件数量与 Manifest 不一致 + + Bootstrap + + + Update Handoff Failed + 更新交接失败 + + + + Bootstrap arguments are incomplete. + Bootstrap 启动参数不完整。 + + + + The update plan is missing or invalid. + 更新计划文件缺失或格式无效。 + + + + Cannot restart Updater. + 无法重新启动 Updater。 + + + + +Failed file: %1 + +失败文件:%1 + + diff --git a/scripts/ReadMe.txt b/scripts/ReadMe.txt index c1539d3..d8cab24 100644 --- a/scripts/ReadMe.txt +++ b/scripts/ReadMe.txt @@ -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 +``` diff --git a/scripts/package-client.sh b/scripts/package-client.sh new file mode 100644 index 0000000..7978d41 --- /dev/null +++ b/scripts/package-client.sh @@ -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" diff --git a/scripts/package-sdk.ps1 b/scripts/package-sdk.ps1 index 7a70ffb..874acb2 100644 --- a/scripts/package-sdk.ps1 +++ b/scripts/package-sdk.ps1 @@ -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 diff --git a/scripts/package-sdk.sh b/scripts/package-sdk.sh new file mode 100644 index 0000000..ebaa4ac --- /dev/null +++ b/scripts/package-sdk.sh @@ -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" <