From b06e0035020ba0181035977194be39fd8443b0d3 Mon Sep 17 00:00:00 2001 From: nikelaluo <2135665716@qq.com> Date: Thu, 9 Jul 2026 09:17:30 +0000 Subject: [PATCH] chore: initialize client repository --- .gitignore | 70 + .gitkeep | 0 Bootstrap/CMakeLists.txt | 6 + Bootstrap/main.cpp | 191 ++ CMakeLists.txt | 70 + Common/CMakeLists.txt | 38 + Common/ConfigHelper.cpp | 181 ++ Common/ConfigHelper.h | 22 + Common/DeviceIdentityHelper.cpp | 49 + Common/DeviceIdentityHelper.h | 15 + Common/FileHelper.cpp | 42 + Common/FileHelper.h | 14 + Common/HttpHelper.cpp | 67 + Common/HttpHelper.h | 17 + Common/IntegrityHelper.cpp | 153 ++ Common/IntegrityHelper.h | 20 + Common/LocalStateHelper.cpp | 147 ++ Common/LocalStateHelper.h | 33 + Common/PolicyHelper.cpp | 116 ++ Common/PolicyHelper.h | 33 + Common/TicketHelper.cpp | 91 + Common/TicketHelper.h | 13 + Launcher/CMakeLists.txt | 46 + Launcher/UpdateLogic.cpp | 117 ++ Launcher/UpdateLogic.h | 38 + Launcher/main.cpp | 282 +++ MainApp/CMakeLists.txt | 21 + MainApp/MainWindow.cpp | 72 + MainApp/MainWindow.h | 11 + MainApp/main.cpp | 130 ++ READMe.txt | 27 + SDK_README.md | 188 ++ Updater/CMakeLists.txt | 41 + Updater/UpdateTransaction.cpp | 274 +++ Updater/UpdateTransaction.h | 54 + Updater/UpdaterLogic.cpp | 751 ++++++++ Updater/UpdaterLogic.h | 73 + Updater/main.cpp | 410 +++++ config/app_config.example.json | 22 + config/manifest_public_key.pem | 9 + install-sdk.ps1 | 74 + package-client.ps1 | 138 ++ package-sdk.ps1 | 116 ++ pdf_requirements.txt | 2943 +++++++++++++++++++++++++++++++ 44 files changed, 7225 insertions(+) create mode 100644 .gitignore create mode 100644 .gitkeep create mode 100644 Bootstrap/CMakeLists.txt create mode 100644 Bootstrap/main.cpp create mode 100644 CMakeLists.txt create mode 100644 Common/CMakeLists.txt create mode 100644 Common/ConfigHelper.cpp create mode 100644 Common/ConfigHelper.h create mode 100644 Common/DeviceIdentityHelper.cpp create mode 100644 Common/DeviceIdentityHelper.h create mode 100644 Common/FileHelper.cpp create mode 100644 Common/FileHelper.h create mode 100644 Common/HttpHelper.cpp create mode 100644 Common/HttpHelper.h create mode 100644 Common/IntegrityHelper.cpp create mode 100644 Common/IntegrityHelper.h create mode 100644 Common/LocalStateHelper.cpp create mode 100644 Common/LocalStateHelper.h create mode 100644 Common/PolicyHelper.cpp create mode 100644 Common/PolicyHelper.h create mode 100644 Common/TicketHelper.cpp create mode 100644 Common/TicketHelper.h create mode 100644 Launcher/CMakeLists.txt create mode 100644 Launcher/UpdateLogic.cpp create mode 100644 Launcher/UpdateLogic.h create mode 100644 Launcher/main.cpp create mode 100644 MainApp/CMakeLists.txt create mode 100644 MainApp/MainWindow.cpp create mode 100644 MainApp/MainWindow.h create mode 100644 MainApp/main.cpp create mode 100644 READMe.txt create mode 100644 SDK_README.md create mode 100644 Updater/CMakeLists.txt create mode 100644 Updater/UpdateTransaction.cpp create mode 100644 Updater/UpdateTransaction.h create mode 100644 Updater/UpdaterLogic.cpp create mode 100644 Updater/UpdaterLogic.h create mode 100644 Updater/main.cpp create mode 100644 config/app_config.example.json create mode 100644 config/manifest_public_key.pem create mode 100644 install-sdk.ps1 create mode 100644 package-client.ps1 create mode 100644 package-sdk.ps1 create mode 100644 pdf_requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1887b16 --- /dev/null +++ b/.gitignore @@ -0,0 +1,70 @@ +# Operating systems and editors +.DS_Store +Thumbs.db +Desktop.ini +.vscode/ +.idea/ +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates + +# Temporary files +*.tmp +*.temp +*.bak +*.swp +*.log +*~ + +# Private requirement / handover documents +*.pdf +*.doc +*.docx + +# C/C++ generated artifacts +*.obj +*.o +*.pdb +*.ilk +*.idb +*.tlog +*.lastbuildstate +*.exp +*.lib +*.dll +*.exe + +# Build outputs +out/ +build/ +build-*/ +cmake-build-*/ +.cmake/ +CMakeFiles/ +CMakeCache.txt +CMakeSettings.json +CMakeUserPresets.json +Testing/ + +# Third-party/business binary drops and generated packages +App/ +dist/ +*.zip +*.tar +*.tar.gz +*.tgz +*.7z +*.rar + +# Local runtime state and credentials +config/app_config.json +config/client_identity.dat +config/local_state.json +config/version_policy.dat +config/*.local.json +config/*private*.pem +config/*private*.key +update/ +update_temp/ diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Bootstrap/CMakeLists.txt b/Bootstrap/CMakeLists.txt new file mode 100644 index 0000000..7b8e31c --- /dev/null +++ b/Bootstrap/CMakeLists.txt @@ -0,0 +1,6 @@ +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) diff --git a/Bootstrap/main.cpp b/Bootstrap/main.cpp new file mode 100644 index 0000000..592d6b6 --- /dev/null +++ b/Bootstrap/main.cpp @@ -0,0 +1,191 @@ +#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 参数不完整。", L"更新接管失败", MB_ICONERROR); + if (argv) LocalFree(argv); + 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"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR); + return 3; + } + const fs::path relative(fromUtf8(line.substr(2))); + if (!safeRelative(relative)) { + MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR); + return 3; + } + items.push_back({static_cast(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 (!launchUpdater(updater, updateArgs, result)) { + std::wstring message = L"无法重新启动 Updater.exe。"; + if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring(); + MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR); + return 4; + } + return (success || rolledBack) ? 0 : 5; +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..3ee01df --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.20) +project(ClientAll LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +if(MSVC) + add_compile_options(/utf-8) +endif() +# Qt全局配置 +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH}) +find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets) +# ========== OpenSSL 手动配置(完全抛弃find_package) ========== +set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64") +set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include") +set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd") +set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD") +# 全局宏,所有子项目统一启用OpenSSL +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) +# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库 +add_subdirectory(Bootstrap) +# 先编译公共库 +add_subdirectory(Common) +# 再编译三个业务程序 +add_subdirectory(Launcher) +add_subdirectory(Updater) +add_subdirectory(MainApp) +# 默认启动项目 +set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) +# Copy client.ini and config directory to output bin folder +set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") +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}") +# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移 +file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}") +file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config") +if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini") + configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY) +endif() +foreach(RUNTIME_RESOURCE local_state.json version_policy.dat) + if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}") + configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY) + endif() +endforeach() +# 编译阶段同步静态配置资源 +add_custom_target(copy_client_resources ALL + COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER} + COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem + COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem + COMMENT "Copy client config directory and manifest public key to output bin folder" +) +add_dependencies(Launcher copy_client_resources) +add_dependencies(Updater copy_client_resources) +add_dependencies(MainApp copy_client_resources) +# Install rules for packaging +if(EXISTS ${CONFIG_SOURCE_DIR}) + install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config) +endif() +if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE}) + install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin) +endif() \ No newline at end of file diff --git a/Common/CMakeLists.txt b/Common/CMakeLists.txt new file mode 100644 index 0000000..c1c5421 --- /dev/null +++ b/Common/CMakeLists.txt @@ -0,0 +1,38 @@ +project(Common LANGUAGES C CXX) +find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets) + +set(SRC + HttpHelper.h + HttpHelper.cpp + FileHelper.h + FileHelper.cpp + ConfigHelper.h + ConfigHelper.cpp + PolicyHelper.h + PolicyHelper.cpp + LocalStateHelper.h + LocalStateHelper.cpp + TicketHelper.h + TicketHelper.cpp + IntegrityHelper.h + IntegrityHelper.cpp + DeviceIdentityHelper.h + DeviceIdentityHelper.cpp +) +add_library(Common STATIC ${SRC}) + +# Common编译自身需要OpenSSL头文件 +target_include_directories(Common + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + 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 +) \ No newline at end of file diff --git a/Common/ConfigHelper.cpp b/Common/ConfigHelper.cpp new file mode 100644 index 0000000..a2bf76c --- /dev/null +++ b/Common/ConfigHelper.cpp @@ -0,0 +1,181 @@ +#include "ConfigHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +ConfigHelper& ConfigHelper::instance() +{ + static ConfigHelper obj; + return obj; +} + +ConfigHelper::ConfigHelper() +{ + m_configPath = QApplication::applicationDirPath() + "/config/app_config.json"; + migrateLegacyIniIfNeeded(); + qDebug() << "Loading app config path:" << m_configPath; + qDebug() << "File exists?" << QFile::exists(m_configPath); +} + +QString ConfigHelper::configPath() const +{ + return m_configPath; +} + +QString ConfigHelper::installRoot() const +{ + QString relativeRoot = getValue("Runtime", "install_root").trimmed(); + if (relativeRoot.isEmpty()) + relativeRoot = "."; + return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot)); +} + +QString ConfigHelper::runtimeRoot() const +{ + return QDir::cleanPath(QApplication::applicationDirPath()); +} + +QString ConfigHelper::updateRoot() const +{ + return QDir(runtimeRoot()).filePath("update"); +} + +QString ConfigHelper::runtimeRelativePath() const +{ + QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot())); + relative = QDir::cleanPath(relative); + if (relative == ".") + return QString(); + while (relative.startsWith("./")) + relative = relative.mid(2); + return relative.trimmed(); +} + +QString ConfigHelper::lastError() const +{ + return m_error; +} + +QString ConfigHelper::getValue(const QString& section, const QString& key) const +{ + Q_UNUSED(section); + QFile file(m_configPath); + if (!file.open(QIODevice::ReadOnly)) + return QString(); + + QJsonParseError error; + const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) + return QString(); + + return document.object().value(key).toVariant().toString(); +} + +bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value) +{ + Q_UNUSED(section); + m_error.clear(); + + QFile input(m_configPath); + QJsonObject config; + if (input.exists()) + { + if (!input.open(QIODevice::ReadOnly)) + { + m_error = QString("Cannot open config for reading: %1").arg(input.errorString()); + return false; + } + QJsonParseError parseError; + const QByteArray raw = input.readAll(); + input.close(); + const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + m_error = QString("Invalid config JSON: %1").arg(parseError.errorString()); + return false; + } + config = document.object(); + } + + config.insert(key, value); + QDir dir(QFileInfo(m_configPath).path()); + if (!dir.exists() && !dir.mkpath(".")) + { + m_error = QString("Cannot create config directory: %1").arg(dir.path()); + return false; + } + + QSaveFile output(m_configPath); + if (!output.open(QIODevice::WriteOnly)) + { + m_error = QString("Cannot open config for writing: %1").arg(output.errorString()); + return false; + } + const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented); + if (output.write(payload) != payload.size()) + { + m_error = QString("Cannot write full config file: %1").arg(output.errorString()); + output.cancelWriting(); + return false; + } + if (!output.commit()) + { + m_error = QString("Cannot commit config file: %1").arg(output.errorString()); + return false; + } + return true; +} + +bool ConfigHelper::migrateLegacyIniIfNeeded() +{ + if (QFile::exists(m_configPath)) + return true; + + const QString legacyPath = QApplication::applicationDirPath() + "/client.ini"; + if (!QFile::exists(legacyPath)) + return false; + + QSettings ini(legacyPath, QSettings::IniFormat); + QJsonObject config; + const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) { + const QString value = ini.value(section + "/" + key, fallback).toString(); + config.insert(key, value); + }; + + copyText("App", "app_id"); + copyText("App", "app_name", "Marsco Demo App"); + copyText("App", "channel", "stable"); + copyText("App", "current_version", "1.0.0"); + copyText("App", "client_protocol", "3"); + copyText("App", "launch_token"); + copyText("License", "license_key"); + copyText("Server", "api_base_url"); + copyText("Server", "client_token"); + copyText("Update", "request_timeout_ms", "5000"); + copyText("Update", "temp_folder", "update_temp"); + copyText("Update", "device_id"); + copyText("Runtime", "install_root", "."); + copyText("Runtime", "main_executable", "MainApp.exe"); + copyText("Runtime", "launcher_executable", "Launcher.exe"); + copyText("Runtime", "updater_executable", "Updater.exe"); + copyText("Runtime", "bootstrap_executable", "Bootstrap.exe"); + copyText("Runtime", "health_check_timeout_ms", "15000"); + config.insert("platform", "windows"); + config.insert("arch", "x64"); + + QDir().mkpath(QFileInfo(m_configPath).path()); + QSaveFile output(m_configPath); + if (!output.open(QIODevice::WriteOnly)) + return false; + output.write(QJsonDocument(config).toJson(QJsonDocument::Indented)); + const bool saved = output.commit(); + if (saved) + qDebug() << "Migrated legacy client.ini to" << m_configPath; + return saved; +} diff --git a/Common/ConfigHelper.h b/Common/ConfigHelper.h new file mode 100644 index 0000000..dd6ce05 --- /dev/null +++ b/Common/ConfigHelper.h @@ -0,0 +1,22 @@ +#pragma once +#include + +class ConfigHelper +{ +public: + static ConfigHelper& instance(); + QString getValue(const QString& section, const QString& key) const; + bool setValue(const QString& section, const QString& key, const QString& value); + QString configPath() const; + QString installRoot() const; + QString runtimeRoot() const; + QString updateRoot() const; + QString runtimeRelativePath() const; + QString lastError() const; + +private: + ConfigHelper(); + bool migrateLegacyIniIfNeeded(); + QString m_configPath; + QString m_error; +}; diff --git a/Common/DeviceIdentityHelper.cpp b/Common/DeviceIdentityHelper.cpp new file mode 100644 index 0000000..e123676 --- /dev/null +++ b/Common/DeviceIdentityHelper.cpp @@ -0,0 +1,49 @@ +#include "DeviceIdentityHelper.h" +#include "ConfigHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef HAVE_OPENSSL +#include +#include +#endif +DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){} +QString DeviceIdentityHelper::deviceId() const{return m_deviceId;} +QString DeviceIdentityHelper::errorString() const{return m_error;} +bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){ +#ifndef HAVE_OPENSSL + Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false; +#else + QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;} + QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;} + EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok; +#endif +} +bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){ + QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true; +} +bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);} +bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){ + m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;} + const QString trimmedBase=base.trimmed(); + if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;} + if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;} + if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;} + if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;} + if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;} + ConfigHelper& config=ConfigHelper::instance(); + QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}} + QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}}; + QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true; +} diff --git a/Common/DeviceIdentityHelper.h b/Common/DeviceIdentityHelper.h new file mode 100644 index 0000000..5b194f1 --- /dev/null +++ b/Common/DeviceIdentityHelper.h @@ -0,0 +1,15 @@ +#pragma once +#include +class DeviceIdentityHelper { +public: + explicit DeviceIdentityHelper(const QString& installDir); + bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId, + const QString& channel, const QString& licenseKey); + bool verifyLocal(const QString& appId, const QString& channel); + QString deviceId() const; + QString errorString() const; +private: + bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel); + bool verifySignature(const QByteArray& payload, const QString& signatureBase64); + QString m_installDir, m_deviceId, m_error; +}; diff --git a/Common/FileHelper.cpp b/Common/FileHelper.cpp new file mode 100644 index 0000000..5aaf239 --- /dev/null +++ b/Common/FileHelper.cpp @@ -0,0 +1,42 @@ +#include "FileHelper.h" +#include + +bool FileHelper::createDir(const QString &path) +{ + QDir dir(path); + if (!dir.exists()) + return dir.mkpath("."); + return true; +} + +bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst) +{ + if (QFile::exists(dst)) + { + if (!QFile::remove(dst)) + { + qDebug() << "无法删除旧文件:" << dst; + return false; + } + } + 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); +} \ No newline at end of file diff --git a/Common/FileHelper.h b/Common/FileHelper.h new file mode 100644 index 0000000..828bbcf --- /dev/null +++ b/Common/FileHelper.h @@ -0,0 +1,14 @@ +#pragma once +#include +#include +#include +#include + +class FileHelper +{ +public: + static bool createDir(const QString& path); + static bool copyFileOverwrite(const QString& src, const QString& dst); + static bool isProcessRunning(const QString& exeName); + static bool killProcess(const QString& exeName); +}; \ No newline at end of file diff --git a/Common/HttpHelper.cpp b/Common/HttpHelper.cpp new file mode 100644 index 0000000..2bc0957 --- /dev/null +++ b/Common/HttpHelper.cpp @@ -0,0 +1,67 @@ +#include "HttpHelper.h" +#include +#include "ConfigHelper.h" +#include +#include +#include +#include + +void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, + std::function callback) +{ + QNetworkAccessManager* manager = new QNetworkAccessManager(); + manager->setProxy(QNetworkProxy::NoProxy); + + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + // Add auth token header + QString token = ConfigHelper::instance().getValue("Server", "client_token"); + req.setRawHeader("X-Client-Token", token.toUtf8()); + QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat")); + if (identity.open(QIODevice::ReadOnly)) + req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); + + QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); + qDebug() << "=== POST Request ==="; + qDebug() << "Url:" << url; + qDebug() << "Body:" << data; + + QNetworkReply* reply = manager->post(req, data); + QEventLoop loop; + bool timeoutOk = false; + int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk); + if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000; + QTimer timer; + timer.setSingleShot(true); + QObject::connect(&timer, &QTimer::timeout, [&]() { + if (reply && reply->isRunning()) { + qDebug() << "Request timeout, abort:" << url; + reply->abort(); + } + }); + + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + timer.start(timeoutMs); + loop.exec(); + timer.stop(); + + int retCode = 0; + QJsonObject retObj; + + retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray respData = reply->readAll(); + if (!respData.isEmpty()) { + qDebug() << "Server raw response:" << respData; + retObj = QJsonDocument::fromJson(respData).object(); + } + if (reply->error() != QNetworkReply::NoError) + { + qDebug() << "Network error code:" << reply->error(); + qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); + } + + callback(retCode, retObj); + + reply->deleteLater(); + manager->deleteLater(); +} \ No newline at end of file diff --git a/Common/HttpHelper.h b/Common/HttpHelper.h new file mode 100644 index 0000000..3bef462 --- /dev/null +++ b/Common/HttpHelper.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +class HttpHelper +{ +public: + // 每次调用独立创建manager,不做成成员变量 + static void postRequest(const QString& url, const QJsonObject& jsonBody, + std::function callback); +}; \ No newline at end of file diff --git a/Common/IntegrityHelper.cpp b/Common/IntegrityHelper.cpp new file mode 100644 index 0000000..83d2e39 --- /dev/null +++ b/Common/IntegrityHelper.cpp @@ -0,0 +1,153 @@ +#include "IntegrityHelper.h" +#include "ConfigHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef HAVE_OPENSSL +#include +#include +#endif + +IntegrityHelper::IntegrityHelper(const QString& installDir) + : m_installDir(QDir::cleanPath(installDir)) {} + +QString IntegrityHelper::errorString() const { return m_error; } + +bool IntegrityHelper::safeRelativePath(const QString& path) const +{ + const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); + return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." + && !clean.startsWith("../") && !clean.contains(":"); +} + +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" + }; + for (const QString& protectedPath : runtimeProtected) + protectedPaths.insert(runtimePrefix + "/" + protectedPath); + } + return protectedPaths.contains(p); +} + +QString IntegrityHelper::sha256(const QString& filePath) const +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + while (!file.atEnd()) hash.addData(file.read(1024 * 1024)); + return QString::fromLatin1(hash.result().toHex()); +} + +bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) +{ +#ifndef HAVE_OPENSSL + Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; +#else + QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem"); + if (!QFile::exists(keyPath)) + keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem"); + QFile keyFile(keyPath); + if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; } + const QByteArray keyData = keyFile.readAll(); + BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); + EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; + if (bio) BIO_free(bio); + if (!key) { m_error = "manifest public key invalid"; return false; } + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); + const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 + && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 + && EVP_DigestVerifyFinal(ctx, reinterpret_cast(signature.constData()), signature.size()) == 1; + if (ctx) EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + if (!ok) m_error = "manifest RSA signature invalid"; + return ok; +#endif +} + +bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, + const QString& version) +{ + m_error.clear(); + QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath( + "manifest_cache/manifest_" + version + ".json"); + const QString legacyCachePath = QDir(m_installDir).filePath( + "update/manifest_cache/manifest_" + version + ".json"); + if (!QFile::exists(cachePath)) + cachePath = legacyCachePath; + QFile cache(cachePath); + if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; } + QJsonParseError wrapperError; + const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); + if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { + m_error = "manifest cache JSON invalid"; return false; + } + const QJsonObject wrapper = wrapperDoc.object(); + const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); + const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); + if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false; + + QJsonParseError manifestError; + const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); + if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { + m_error = "signed manifest payload invalid"; return false; + } + const QJsonObject manifest = manifestDoc.object(); + if (manifest.value("app_id").toString() != appId + || manifest.value("channel").toString() != channel + || manifest.value("version").toString() != version) { + m_error = "manifest identity does not match local application"; return false; + } + + QSet declaredExecutables; + for (const QJsonValue& value : manifest.value("files").toArray()) { + const QJsonObject item = value.toObject(); + const QString path = QDir::fromNativeSeparators(item.value("path").toString()); + if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; } + if (runtimeProtectedPath(path)) continue; + const QString fullPath = QDir(m_installDir).filePath(path); + if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; } + const QString expected = item.value("sha256").toString(); + const QString actual = sha256(fullPath); + if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { + m_error = "file hash mismatch: " + path; return false; + } + const QString suffix = QFileInfo(path).suffix().toCaseFolded(); + if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded()); + } + + QDir root(m_installDir); + QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + const QString fullPath = it.next(); + const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath)); + const QString folded = relative.toCaseFolded(); + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + const bool runtimeWorkDir = !runtimePrefix.isEmpty() + && (folded.startsWith(runtimePrefix + "/update/") + || folded.startsWith(runtimePrefix + "/update_temp/")); + if (folded.startsWith("update/") || folded.startsWith("update_temp/") + || runtimeWorkDir || runtimeProtectedPath(relative)) continue; + const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); + if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { + m_error = "undeclared executable or plugin: " + relative; return false; + } + } + return true; +} diff --git a/Common/IntegrityHelper.h b/Common/IntegrityHelper.h new file mode 100644 index 0000000..202bfc7 --- /dev/null +++ b/Common/IntegrityHelper.h @@ -0,0 +1,20 @@ +#pragma once +#include + +class IntegrityHelper +{ +public: + explicit IntegrityHelper(const QString& installDir); + bool verifyInstalledVersion(const QString& appId, const QString& channel, + const QString& version); + QString errorString() const; + +private: + bool verifySignature(const QByteArray& payload, const QString& signatureBase64); + bool safeRelativePath(const QString& path) const; + bool runtimeProtectedPath(const QString& path) const; + QString sha256(const QString& filePath) const; + + QString m_installDir; + QString m_error; +}; diff --git a/Common/LocalStateHelper.cpp b/Common/LocalStateHelper.cpp new file mode 100644 index 0000000..330e8a8 --- /dev/null +++ b/Common/LocalStateHelper.cpp @@ -0,0 +1,147 @@ +#include "LocalStateHelper.h" +#include +#include +#include +#include + +LocalStateHelper::LocalStateHelper(const QString& baseDir) + : m_baseDir(baseDir) + , m_filePath(baseDir + "/config/local_state.json") +{ +} + +bool LocalStateHelper::loadState(const QString& relativePath) +{ + m_filePath = m_baseDir + "/" + relativePath; + QFile file(m_filePath); + if (!file.exists()) + { + QDir dir(QFileInfo(m_filePath).path()); + if (!dir.exists() && !dir.mkpath(".")) + { + m_error = QString("Cannot create state directory: %1").arg(dir.path()); + return false; + } + + m_state = QJsonObject{ + {"max_policy_seq", 0}, + {"last_success_run_at", QString()}, + {"last_online_verified_at", QString()}, + {"last_success_version", QString()} + }; + m_loaded = true; + if (!saveState()) + { + m_error = QString("Cannot write new state file: %1").arg(m_filePath); + return false; + } + return true; + } + + if (!file.open(QIODevice::ReadOnly)) + { + m_error = QString("Cannot open state file: %1").arg(m_filePath); + return false; + } + + QByteArray raw = file.readAll(); + file.close(); + + QJsonParseError parseError; + QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) + { + m_error = QString("Invalid state JSON: %1").arg(parseError.errorString()); + return false; + } + + m_state = doc.object(); + m_loaded = true; + return true; +} + +bool LocalStateHelper::saveState() const +{ + if (!m_loaded) + return false; + + QFile file(m_filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + return false; + } + + QJsonDocument doc(m_state); + file.write(doc.toJson(QJsonDocument::Indented)); + file.close(); + return true; +} + +bool LocalStateHelper::isLoaded() const +{ + return m_loaded; +} + +QString LocalStateHelper::errorString() const +{ + return m_error; +} + +qint64 LocalStateHelper::maxPolicySeq() const +{ + return m_state.value("max_policy_seq").toVariant().toLongLong(); +} + +QDateTime LocalStateHelper::lastSuccessRunAt() const +{ + return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate); +} + +QDateTime LocalStateHelper::lastOnlineVerifiedAt() const +{ + return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate); +} + +QString LocalStateHelper::lastSuccessVersion() const +{ + return m_state.value("last_success_version").toString(); +} + +bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const +{ + if (!m_loaded) + return false; + return policySeq < maxPolicySeq(); +} + +bool LocalStateHelper::isSystemTimeRewound() const +{ + if (!m_loaded) + return false; + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QDateTime lastRun = lastSuccessRunAt(); + const QDateTime lastOnline = lastOnlineVerifiedAt(); + return (lastRun.isValid() && now < lastRun) + || (lastOnline.isValid() && now < lastOnline); +} + +void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq) +{ + if (!m_loaded) + m_loaded = true; + + m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + m_state["last_success_version"] = currentVersion; + if (policySeq > maxPolicySeq()) + m_state["max_policy_seq"] = policySeq; +} + +void LocalStateHelper::updateOnlineVerified(qint64 policySeq) +{ + if (!m_loaded) + m_loaded = true; + + m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + if (policySeq > maxPolicySeq()) + m_state["max_policy_seq"] = policySeq; +} diff --git a/Common/LocalStateHelper.h b/Common/LocalStateHelper.h new file mode 100644 index 0000000..dd166c5 --- /dev/null +++ b/Common/LocalStateHelper.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include +#include + +class LocalStateHelper +{ +public: + explicit LocalStateHelper(const QString& baseDir); + + bool loadState(const QString& relativePath = "config/local_state.json"); + bool saveState() const; + bool isLoaded() const; + QString errorString() const; + + qint64 maxPolicySeq() const; + QDateTime lastSuccessRunAt() const; + QDateTime lastOnlineVerifiedAt() const; + QString lastSuccessVersion() const; + + bool isPolicySeqRolledBack(qint64 policySeq) const; + bool isSystemTimeRewound() const; + + void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq); + void updateOnlineVerified(qint64 policySeq); + +private: + QString m_baseDir; + QString m_filePath; + QString m_error; + QJsonObject m_state; + bool m_loaded = false; +}; diff --git a/Common/PolicyHelper.cpp b/Common/PolicyHelper.cpp new file mode 100644 index 0000000..cafdb6f --- /dev/null +++ b/Common/PolicyHelper.cpp @@ -0,0 +1,116 @@ +#include "PolicyHelper.h" +#include +#include +#include +#include +#include +#include +#include +#ifdef HAVE_OPENSSL +#include +#include +#endif + +PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {} + +bool PolicyHelper::loadPolicy(const QString& relativePath) +{ + QFile file(m_baseDir + "/" + relativePath); + if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; } + QJsonParseError error; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !doc.isObject()) { + m_error = "Invalid policy JSON: " + error.errorString(); return false; + } + return loadPolicyObject(doc.object()); +} + +bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText) +{ + m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear(); + return isValid(); +} + +bool PolicyHelper::savePolicy(const QString& relativePath) const +{ + QSaveFile file(m_baseDir + "/" + relativePath); + if (!file.open(QIODevice::WriteOnly)) return false; + const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented); + return file.write(bytes) == bytes.size() && file.commit(); +} + +QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const +{ + QJsonObject copy = policy; copy.remove("signature"); + auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject { + QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end()); + QJsonObject sorted; + for (const QString& key : keys) { + QJsonValue value = obj.value(key); + if (value.isObject()) value = self(self, value.toObject()); + else if (value.isArray()) { + QJsonArray array; + for (QJsonValue item : value.toArray()) { + if (item.isObject()) item = self(self, item.toObject()); + array.append(item); + } + value = array; + } + sorted.insert(key, value); + } + return sorted; + }; + return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact); +} + +bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const +{ +#ifndef HAVE_OPENSSL + Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; +#else + QString keyPath = m_baseDir + "/config/manifest_public_key.pem"; + QFile keyFile(keyPath); + if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; } + const QByteArray keyData = keyFile.readAll(); + BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); + EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; + if (bio) BIO_free(bio); + if (!key) { m_error = "Invalid policy public key"; return false; } + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); + bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1 + && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 + && EVP_DigestVerifyFinal(ctx, reinterpret_cast(signature.constData()), signature.size()) == 1; + if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key); + if (!ok) m_error = "Invalid RSA policy signature"; + return ok; +#endif +} + +bool PolicyHelper::isValid() const +{ + if (!m_loaded) return false; + const QStringList required{"app_id","channel","current_version","policy_seq","allow_run", + "force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"}; + for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; } + if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false; + const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); + return verifySignature(payload, m_policy.value("signature").toString()); +} +QString PolicyHelper::errorString() const { return m_error; } +bool PolicyHelper::isVersionAllowed(const QString& version) const { + if (!isValid()) return false; + for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false; + return true; +} +bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); } +bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); } +bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); } +bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); } +bool PolicyHelper::isExpired() const { + if (!isValid()) return true; + const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate); + return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry; +} +qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; } +QString PolicyHelper::message() const { return m_policy.value("message").toString(); } diff --git a/Common/PolicyHelper.h b/Common/PolicyHelper.h new file mode 100644 index 0000000..fc28fd0 --- /dev/null +++ b/Common/PolicyHelper.h @@ -0,0 +1,33 @@ +#pragma once +#include +#include + +class PolicyHelper +{ +public: + explicit PolicyHelper(const QString& baseDir); + + bool loadPolicy(const QString& relativePath = "config/version_policy.dat"); + bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString()); + bool savePolicy(const QString& relativePath = "config/version_policy.dat") const; + bool isValid() const; + QString errorString() const; + bool isVersionAllowed(const QString& currentVersion) const; + bool allowRun() const; + bool forceUpdate() const; + bool allowRollback() const; + bool isOfflineAllowed() const; + bool isExpired() const; + qint64 policySeq() const; + QString message() const; + +private: + QByteArray canonicalPolicyBytes(const QJsonObject& obj) const; + bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const; + + QString m_baseDir; + QJsonObject m_policy; + QString m_signedText; + mutable QString m_error; + bool m_loaded = false; +}; diff --git a/Common/TicketHelper.cpp b/Common/TicketHelper.cpp new file mode 100644 index 0000000..9cec298 --- /dev/null +++ b/Common/TicketHelper.cpp @@ -0,0 +1,91 @@ +#include "TicketHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) +{ + return QMessageAuthenticationCode::hash( + QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex(); +} + +bool TicketHelper::createTicket(const QString& appId, const QString& deviceId, + const QString& version, const QString& secret, + QString* ticketPath, QString* errorMessage) +{ + if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) { + if (errorMessage) *errorMessage = "ticket identity or secret is empty"; + return false; + } + const QDateTime now = QDateTime::currentDateTimeUtc(); + QJsonObject payload{ + {"app_id", appId}, {"device_id", deviceId}, {"version", version}, + {"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)}, + {"issued_at", now.toString(Qt::ISODate)}, + {"expires_at", now.addSecs(60).toString(Qt::ISODate)}, + {"signature_alg", "HMAC-SHA256"} + }; + QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}}; + const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets"); + if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; } + const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); + QSaveFile file(path); + const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { + if (errorMessage) *errorMessage = "cannot save ticket"; + return false; + } + QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); + if (ticketPath) *ticketPath = path; + return true; +} + +bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, + const QString& expectedDeviceId, const QString& expectedVersion, + const QString& secret, QString* errorMessage) +{ + const QString consumingPath = ticketPath + ".consuming." + + QString::number(QCoreApplication::applicationPid()); + if (!QFile::rename(ticketPath, consumingPath)) { + if (errorMessage) *errorMessage = "ticket missing or already consumed"; + return false; + } + QFile file(consumingPath); + if (!file.open(QIODevice::ReadOnly)) { + QFile::remove(consumingPath); + if (errorMessage) *errorMessage = "cannot read claimed ticket"; + return false; + } + const QByteArray raw = file.readAll(); file.close(); + QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。 + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + if (errorMessage) *errorMessage = "invalid ticket JSON"; return false; + } + const QJsonObject wrapper = doc.object(); + const QJsonObject payload = wrapper.value("payload").toObject(); + const QByteArray actual = wrapper.value("signature").toString().toLatin1(); + const QByteArray expected = ticketMac(payload, secret); + 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; +} diff --git a/Common/TicketHelper.h b/Common/TicketHelper.h new file mode 100644 index 0000000..9531204 --- /dev/null +++ b/Common/TicketHelper.h @@ -0,0 +1,13 @@ +#pragma once +#include + +class TicketHelper +{ +public: + static bool createTicket(const QString& appId, const QString& deviceId, + const QString& version, const QString& secret, + QString* ticketPath, QString* errorMessage = nullptr); + static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, + const QString& expectedDeviceId, const QString& expectedVersion, + const QString& secret, QString* errorMessage = nullptr); +}; diff --git a/Launcher/CMakeLists.txt b/Launcher/CMakeLists.txt new file mode 100644 index 0000000..2063e02 --- /dev/null +++ b/Launcher/CMakeLists.txt @@ -0,0 +1,46 @@ +cmake_minimum_required(VERSION 3.20) +project(LauncherDemo LANGUAGES C CXX) +# C++标准 +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +# Qt 自动处理ui/moc/rcc +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTOUIC ON) +set(CMAKE_AUTORCC ON) +# 依赖Qt模块:网络、基础核心、窗口 +set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH}) +find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets) +# 所有源码文件 +set(SRC_LIST + main.cpp + UpdateLogic.h + UpdateLogic.cpp +) +# 生成可执行程序 +add_executable(Launcher ${SRC_LIST}) +if(WIN32) + set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE) +endif() + +# 关键:添加OpenSSL头文件目录 +target_include_directories(Launcher PRIVATE ${OPENSSL_INC}) + +# 链接Common,自动带上OpenSSL库 +target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets) +# 输出编译产物到 out 文件夹(干净不乱) +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) +# 强制VS打开CMake文件夹时默认选中该可执行目标 +set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher) +# 编译完成自动执行windeployqt,复制Qt dll到exe目录 +if(WIN32) + 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 Launcher POST_BUILD + COMMAND ${WINDEPLOYQT} $,--debug,--release> $ + COMMENT "自动部署Qt依赖dll" + ) +endif() diff --git a/Launcher/UpdateLogic.cpp b/Launcher/UpdateLogic.cpp new file mode 100644 index 0000000..a8f0024 --- /dev/null +++ b/Launcher/UpdateLogic.cpp @@ -0,0 +1,117 @@ +#include "UpdateLogic.h" +#include "ConfigHelper.h" +#include +#include +#include +#include "PolicyHelper.h" +#include "LocalStateHelper.h" + +UpdateLogic::UpdateLogic(QObject* parent) + : QObject(parent) +{ + ConfigHelper& cfg = ConfigHelper::instance(); + m_serverAddr = cfg.getValue("Server", "api_base_url"); + m_appId = cfg.getValue("App", "app_id"); + m_curVer = cfg.getValue("App", "current_version"); + m_channel = cfg.getValue("App", "channel"); + + // Debug print config + qDebug() << "Read server addr:" << m_serverAddr; + qDebug() << "Read app id:" << m_appId; +} + +void UpdateLogic::checkUpdate() +{ + if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) + { + qDebug() << "Config incomplete, abort update check"; + m_needUpdate = false; + return; + } + QString url = m_serverAddr + "/api/v1/update/check"; + QJsonObject body; + body["app_id"] = m_appId; + body["current_version"] = m_curVer; + body["channel"] = m_channel; + const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt(); + body["client_protocol"] = qMax(3, configuredProtocol); + + m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) + { + qDebug() << "Check update HTTP code:" << code; + m_lastStatusCode = code; + m_checkResp = resp; + m_networkOk = (code == 200); + if (code == 200) + { + const QString appDir = QApplication::applicationDirPath(); + PolicyHelper onlinePolicy(appDir); + LocalStateHelper state(appDir); + const bool stateLoaded = state.loadState(); + const bool policyValid = onlinePolicy.loadPolicyObject( + resp.value("policy").toObject(), resp.value("policy_text").toString()); + if (!policyValid || !stateLoaded + || state.isPolicySeqRolledBack(onlinePolicy.policySeq()) + || !onlinePolicy.savePolicy()) + { + qDebug() << "Online policy rejected:" << onlinePolicy.errorString(); + m_networkOk = false; + m_needUpdate = false; + return; + } + state.updateOnlineVerified(onlinePolicy.policySeq()); + if (!state.saveState()) + { + qDebug() << "Cannot persist online policy state"; + m_networkOk = false; + m_needUpdate = false; + return; + } + m_needUpdate = resp["need_update"].toBool(); + m_latestVer = resp["latest_version"].toString(); + qDebug() << "Need update:" << m_needUpdate; + qDebug() << "Latest version:" << m_latestVer; + qDebug() << "Policy seq:" << onlinePolicy.policySeq(); + } + else + { + m_needUpdate = false; + qDebug() << "Check update api failed"; + } + }); +} + +void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId) +{ + QString url = m_serverAddr + "/api/v1/update/download-url"; + QJsonObject body; + body["app_id"] = appId; + body["channel"] = channel; + body["version"] = ver; + body["version_id"] = verId; + QJsonArray files; + body["files"] = files; + + m_http.postRequest(url, body, [](int code, const QJsonObject& resp) + { + qDebug() << "\n========== File Download Url =========="; + qDebug() << resp["files"].toArray(); + }); +} + +void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success) +{ + QString url = m_serverAddr + "/api/v1/update/report"; + QJsonObject body; + body["app_id"] = m_appId; + body["device_id"] = deviceId; + body["from_version"] = fromVer; + body["to_version"] = toVer; + body["result"] = success ? "success" : "fail"; + + m_http.postRequest(url, body, [](int code, const QJsonObject& resp) + { + qDebug() << "\n========== Update Report Result =========="; + qDebug() << resp; + }); +} \ No newline at end of file diff --git a/Launcher/UpdateLogic.h b/Launcher/UpdateLogic.h new file mode 100644 index 0000000..3f2ea84 --- /dev/null +++ b/Launcher/UpdateLogic.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include "HttpHelper.h" +#include +#include + +class UpdateLogic : public QObject +{ + Q_OBJECT +public: + explicit UpdateLogic(QObject* parent = nullptr); + + void checkUpdate(); + void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId); + void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); + + bool getNeedUpdate() const { return m_needUpdate; } + QString getLatestVersion() const { return m_latestVer; } + QJsonObject getCheckResult() const { return m_checkResp; } + bool isNetworkOk() const { return m_networkOk; } + int lastStatusCode() const { return m_lastStatusCode; } + + QString getAppId() const { return m_appId; } + QString getChannel() const { return m_channel; } + +private: + HttpHelper m_http; + QString m_serverAddr; + QString m_appId; + QString m_curVer; + QString m_channel; + + bool m_needUpdate = false; + bool m_networkOk = false; + int m_lastStatusCode = 0; + QString m_latestVer; + QJsonObject m_checkResp; +}; \ No newline at end of file diff --git a/Launcher/main.cpp b/Launcher/main.cpp new file mode 100644 index 0000000..8a1c5fa --- /dev/null +++ b/Launcher/main.cpp @@ -0,0 +1,282 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "UpdateLogic.h" +#include "../Common/ConfigHelper.h" +#include "../Common/PolicyHelper.h" +#include "../Common/LocalStateHelper.h" +#include "../Common/TicketHelper.h" +#include "../Common/DeviceIdentityHelper.h" +#include +#include +#include + +int main(int argc, char* argv[]) +{ + SetConsoleOutputCP(65001); + QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); + QApplication app(argc, argv); + QApplication::setApplicationName("Marsco Launcher"); + + QProgressDialog progress("正在检查软件更新...", QString(), 0, 0); + progress.setWindowTitle("Marsco 软件启动器"); + progress.setCancelButton(nullptr); + progress.setWindowModality(Qt::ApplicationModal); + progress.setMinimumDuration(0); + progress.setAutoClose(false); + progress.show(); + QApplication::processEvents(); + + const QString appDir = QApplication::applicationDirPath(); + ConfigHelper& config = ConfigHelper::instance(); + const auto isLicenseError = [](const QString& errorText) { + const QString text = errorText.toLower(); + return text.contains("license") + || errorText.contains("授权") + || errorText.contains("过期") + || errorText.contains("设备数量已达到上限") + || errorText.contains("已绑定其他授权"); + }; + const auto clearDeviceCredential = [&]() { + QFile::remove(QDir(appDir).filePath("config/client_identity.dat")); + config.setValue("Update", "device_id", QString()); + }; + QString licenseKey = config.getValue("License", "license_key").trimmed(); + auto promptAndSaveLicense = [&](const QString& reason) -> QString { + QString promptReason = reason; + while (true) { + progress.close(); + bool accepted = false; + const QString appName = config.getValue("App", "app_name").trimmed(); + const QString prompt = promptReason.trimmed().isEmpty() + ? QString("请输入%1授权 License:").arg(appName.isEmpty() ? "软件" : appName) + : QString("%1\n\n请重新输入%2授权 License:").arg(promptReason, appName.isEmpty() ? "软件" : appName); + licenseKey = QInputDialog::getText( + nullptr, + "输入 License", + prompt, + QLineEdit::Normal, + QString(), + &accepted + ).trimmed(); + if (!accepted) { + QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。"); + return QString(); + } + if (licenseKey.isEmpty()) { + QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。"); + promptReason.clear(); + continue; + } + if (!config.setValue("License", "license_key", licenseKey)) { + QMessageBox::critical(nullptr, "保存 License 失败", + QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError())); + return QString(); + } + progress.show(); + progress.setLabelText("正在验证 License..."); + QApplication::processEvents(); + return licenseKey; + } + }; + + while (true) { + if (licenseKey.isEmpty()) { + licenseKey = promptAndSaveLicense(QString()); + if (licenseKey.isEmpty()) return 0; + } + DeviceIdentityHelper identity(appDir); + if (identity.ensureIssued(config.getValue("Server", "api_base_url"), + config.getValue("Server", "client_token"), + config.getValue("App", "app_id"), + config.getValue("App", "channel"), + licenseKey)) { + break; + } + const QString error = identity.errorString(); + if (!isLicenseError(error)) { + progress.close(); + QMessageBox::critical(nullptr, "设备身份验证失败", error); + return -1; + } + clearDeviceCredential(); + licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error)); + if (licenseKey.isEmpty()) return 0; + } + + UpdateLogic logic; + logic.checkUpdate(); + + const bool needUpdate = logic.getNeedUpdate(); + const bool networkOk = logic.isNetworkOk(); + const QString latestVer = logic.getLatestVersion(); + const QJsonObject response = logic.getCheckResult(); + const int targetVersionId = response.value("version_id").toInt(); + const QString appId = logic.getAppId(); + const QString channel = logic.getChannel(); + + if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { + progress.close(); + const QJsonValue detail = response.value("detail"); + const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); + const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message; + if (isLicenseError(displayMessage)) { + clearDeviceCredential(); + const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage)); + if (!newLicense.isEmpty()) { + progress.close(); + QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。"); + } + return 0; + } + QMessageBox::critical(nullptr, "授权被拒绝", displayMessage); + return -1; + } + + const QString launchToken = config.getValue("App", "launch_token"); + const QString currentVersion = config.getValue("App", "current_version"); + const auto configuredName = [&](const QString& key, const QString& fallback) { + const 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 mainAppPath = QDir(appDir).filePath(mainExecutable); + const QString updaterPath = QDir(appDir).filePath(updaterExecutable); + const auto importOfflinePackage = [&]() { + const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)"); + return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)}); + }; + const QString deviceId = config.getValue("Update", "device_id"); + if (QCoreApplication::arguments().contains("--import-offline")) { + progress.close(); + if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。"); + return 0; + } + + const auto startMainApp = [&]() { + QString ticketPath; + QString ticketError; + if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, + launchToken, &ticketPath, &ticketError)) { + qDebug() << "Cannot create launch ticket:" << ticketError; + return false; + } + const bool started = QProcess::startDetached(mainAppPath, + QStringList{QString("--ticket-file=%1").arg(ticketPath)}); + if (!started) QFile::remove(ticketPath); + return started; + }; + + progress.setLabelText("正在验证本地运行策略..."); + QApplication::processEvents(); + + PolicyHelper policy(appDir); + if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) + { + progress.close(); + QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString())); + return -1; + } + if (policy.isExpired()) + { + progress.close(); + QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。"); + return -1; + } + if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) + { + progress.close(); + QMessageBox::critical(nullptr, "当前版本不可运行", + policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion) + : policy.message()); + return -1; + } + + LocalStateHelper state(appDir); + if (!state.loadState()) + { + progress.close(); + QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString())); + return -1; + } + if (state.isPolicySeqRolledBack(policy.policySeq())) + { + progress.close(); + QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。"); + return -1; + } + if (state.isSystemTimeRewound()) + { + progress.close(); + QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。"); + return -1; + } + + if (networkOk && needUpdate) + { + progress.close(); + const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; + const QString dialogTitle = rollbackOperation ? "版本回退" + : (policy.forceUpdate() ? "必须更新" : "发现新版本"); + const QString prompt = policy.message().isEmpty() + ? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?" + : "发现新版本 %1,是否现在更新?").arg(latestVer) + : policy.message() + QString("\n目标版本:%1").arg(latestVer); + bool accepted = true; + if (policy.forceUpdate()) { + QMessageBox::information(nullptr, dialogTitle, prompt); + } else { + accepted = QMessageBox::question(nullptr, dialogTitle, prompt, + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; + } + if (!accepted) { + if (!startMainApp()) { + QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); + return -1; + } + return 0; + } + const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; + if (!QProcess::startDetached(updaterPath, updaterArgs)) + { + QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath)); + return -1; + } + return 0; + } + + if (!networkOk) { + progress.close(); + if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { + if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。"); + return 0; + } + progress.show(); + } + + if (!networkOk && !policy.isOfflineAllowed()) + { + progress.close(); + QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。"); + return -1; + } + + progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); + QApplication::processEvents(); + if (!startMainApp()) + { + progress.close(); + QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); + return -1; + } + progress.close(); + return 0; +} diff --git a/MainApp/CMakeLists.txt b/MainApp/CMakeLists.txt new file mode 100644 index 0000000..7182c39 --- /dev/null +++ b/MainApp/CMakeLists.txt @@ -0,0 +1,21 @@ +project(MainApp) +add_executable(MainApp + main.cpp + MainWindow.h + MainWindow.cpp + ../Common/ConfigHelper.h +) +target_include_directories(MainApp + PUBLIC ${CMAKE_SOURCE_DIR}/Common + PRIVATE ${OPENSSL_INC} +) +target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets) + +if(WIN32) + 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 MainApp POST_BUILD + COMMAND ${WINDEPLOYQT} $,--debug,--release> $ + ) +endif() diff --git a/MainApp/MainWindow.cpp b/MainApp/MainWindow.cpp new file mode 100644 index 0000000..77f4c58 --- /dev/null +++ b/MainApp/MainWindow.cpp @@ -0,0 +1,72 @@ +#include "MainWindow.h" +#include +#include +#include +#include +#include +#include +#include "../Common/PolicyHelper.h" +#include "../Common/ConfigHelper.h" + +static QString readDllVersion(const QString& dllPath) +{ + QFile file(dllPath); + if (!file.exists()) + return "missing"; + + if (!file.open(QIODevice::ReadOnly)) + return "unreadable"; + + QByteArray data = file.read(1024); + file.close(); + return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8)); +} + +MainWindow::MainWindow(QWidget* parent) + : QWidget(parent) +{ + this->setWindowTitle("Marsco Demo MainApp"); + this->resize(600, 400); + + QString appVersion = ConfigHelper::instance().getValue("App", "current_version"); + if (appVersion.isEmpty()) + appVersion = "unknown"; + + QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll"); + + PolicyHelper policy(QApplication::applicationDirPath()); + QString policyResult; + if (!policy.loadPolicy("config/version_policy.dat")) + { + policyResult = "policy missing"; + } + else if (!policy.isValid()) + { + policyResult = "policy invalid"; + } + else if (!policy.isVersionAllowed(appVersion)) + { + policyResult = "version disabled"; + } + else if (policy.isExpired()) + { + policyResult = "policy expired"; + } + else + { + policyResult = "policy ok"; + } + + QVBoxLayout* layout = new QVBoxLayout(this); + QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3") + .arg(appVersion) + .arg(dllVersion) + .arg(policyResult)); + QFont font = label->font(); + font.setPointSize(14); + label->setFont(font); + label->setAlignment(Qt::AlignCenter); + + layout->addWidget(label); + this->setLayout(layout); +} \ No newline at end of file diff --git a/MainApp/MainWindow.h b/MainApp/MainWindow.h new file mode 100644 index 0000000..0c57cc2 --- /dev/null +++ b/MainApp/MainWindow.h @@ -0,0 +1,11 @@ +#pragma once +#include +#include +#include + +class MainWindow : public QWidget +{ + Q_OBJECT +public: + explicit MainWindow(QWidget* parent = nullptr); +}; \ No newline at end of file diff --git a/MainApp/main.cpp b/MainApp/main.cpp new file mode 100644 index 0000000..4d73d19 --- /dev/null +++ b/MainApp/main.cpp @@ -0,0 +1,130 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include "MainWindow.h" +#include "../Common/ConfigHelper.h" +#include "../Common/PolicyHelper.h" +#include "../Common/LocalStateHelper.h" +#include "../Common/TicketHelper.h" +#include "../Common/IntegrityHelper.h" +#include "../Common/DeviceIdentityHelper.h" + +int main(int argc, char* argv[]) +{ + SetConsoleOutputCP(936); + QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK")); + + QApplication a(argc, argv); + + qDebug() << Qt::endl << "entered main app" << Qt::endl; + + ConfigHelper& config = ConfigHelper::instance(); + QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); + if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; + QString ticketFilePath; + QString healthFilePath; + for (int i = 1; i < argc; ++i) + { + const QString arg(argv[i]); + if (arg.startsWith("--ticket-file=")) + ticketFilePath = arg.mid(QString("--ticket-file=").size()); + else if (arg.startsWith("--health-file=")) + healthFilePath = arg.mid(QString("--health-file=").size()); + } + + QString ticketError; + if (ticketFilePath.isEmpty() + || !TicketHelper::consumeAndVerify(ticketFilePath, + config.getValue("App", "app_id"), config.getValue("Update", "device_id"), + config.getValue("App", "current_version"), config.getValue("App", "launch_token"), + &ticketError)) + { + QMessageBox::critical(nullptr, "Startup Restriction", + QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") + .arg(ticketError, launcherExecutable)); + return -1; + } + + QString appDir = QApplication::applicationDirPath(); + const QString installRoot = config.installRoot(); + DeviceIdentityHelper identity(appDir); + if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) + { + QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString())); + return -1; + } + PolicyHelper policy(appDir); + if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) + { + QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString())); + return -1; + } + + if (policy.isExpired()) + { + QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application."); + return -1; + } + + LocalStateHelper state(appDir); + if (!state.loadState()) + { + QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString())); + return -1; + } + + if (state.isPolicySeqRolledBack(policy.policySeq())) + { + QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked."); + return -1; + } + + if (state.isSystemTimeRewound()) + { + QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked."); + return -1; + } + + IntegrityHelper integrity(installRoot); + if (!integrity.verifyInstalledVersion( + config.getValue("App", "app_id"), config.getValue("App", "channel"), + config.getValue("App", "current_version"))) + { + QMessageBox::critical(nullptr, "Integrity Check Failed", + QString("Application files failed signed Manifest verification:\n%1") + .arg(integrity.errorString())); + return -1; + } + + MainWindow w; + w.show(); + + state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq()); + if (!state.saveState()) + { + QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString())); + return -1; + } + + if (!healthFilePath.isEmpty()) + { + QDir().mkpath(QFileInfo(healthFilePath).path()); + QSaveFile healthFile(healthFilePath); + const QByteArray healthy("ok\n"); + if (!healthFile.open(QIODevice::WriteOnly) + || healthFile.write(healthy) != healthy.size() + || !healthFile.commit()) + { + QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file."); + return -1; + } + } + + qDebug() << "Main program MainApp is running normally"; + return a.exec(); +} diff --git a/READMe.txt b/READMe.txt new file mode 100644 index 0000000..328294c --- /dev/null +++ b/READMe.txt @@ -0,0 +1,27 @@ +客户端配置说明 +============== + +统一从程序目录下的 config/app_config.json 读取配置。 +首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。 + +接入新软件时通常需要修改: + +1. app_id、app_name、channel、current_version。 +2. api_base_url、client_token、license_key、launch_token。 +3. main_executable:团队业务主程序文件名。 +4. launcher_executable、updater_executable、bootstrap_executable。 +5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。 + +完整格式参考 config/app_config.example.json。 +运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。 + +Windows 发布打包: + +1. 使用 Release 配置编译全部客户端程序。 +2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。 +3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。 +4. 在 PowerShell 执行: + powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json +5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。 + +脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。 diff --git a/SDK_README.md b/SDK_README.md new file mode 100644 index 0000000..30f3b0a --- /dev/null +++ b/SDK_README.md @@ -0,0 +1,188 @@ +# UpdateClientSDK 接入说明 + +这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。 + +SDK 核心程序: + +- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。 +- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。 +- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。 +- `config/app_config.json`:接入方配置。 +- `config/manifest_public_key.pem`:验证服务端签名用的公钥。 + +## 接入方需要做什么 + +假设接入的软件叫 `YourApp.exe`。 + +1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。 +2. 创建或确认渠道,例如 `stable`。 +3. 创建 License,把生成的 `license_key` 填到客户端配置。 +4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`。 +5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。 +6. 把 `config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。 +7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。 +8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。 + +## 安装目录写权限要求 + +当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如: + +```text +config/app_config.json +config/client_identity.dat +config/local_state.json +config/version_policy.dat +update/ +update_temp/ +``` + +所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`。 + +推荐联调目录: + +```text +D:\SimCAE_Release\ +C:\Users\<你的用户名>\Desktop\SimCAE_Release\ +``` + +如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。 + +## SimCAE 目录结构建议 + +SimCAE 当前安装目录是根目录下有 `bin/`、`Licenses/`、`installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录: + +```text +SimCAE/ + bin/ + Launcher.exe + Updater.exe + Bootstrap.exe + SimCAE.exe + config/ + app_config.json + manifest_public_key.pem + update/ + manifest_cache/ + Qt5Core.dll + ... + Licenses/ + installerResources/ + maintenancetool.exe +``` + +这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root` 填 `..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`。 + +注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll`、`platforms/`、`imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。 + +## app_config.json 关键字段 + +```json +{ + "app_id": "simcae", + "app_name": "SimCAE", + "channel": "stable", + "current_version": "1.0.0", + "client_protocol": "3", + "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", + "license_key": "", + "api_base_url": "http://YOUR_SERVER_IP:8000", + "client_token": "SimCAEClientToken2026", + "request_timeout_ms": "5000", + "temp_folder": "update_temp", + "device_id": "", + "install_root": "..", + "main_executable": "SimCAE.exe", + "launcher_executable": "Launcher.exe", + "updater_executable": "Updater.exe", + "bootstrap_executable": "Bootstrap.exe", + "health_check_timeout_ms": "15000", + "platform": "windows", + "arch": "x64" +} +``` + +字段说明: + +- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。 +- `channel`:发布渠道,例如 `stable`、`beta`、`dev`。 +- `current_version`:当前客户端初始版本。 +- `client_protocol`:客户端协议号,当前建议为 `3`。 +- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。 +- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。 +- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。 +- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。 +- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。 +- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`。 +- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。 + +## 业务主程序需要配合什么 + +当前安全模式下,业务主程序需要配合两件事: + +1. 接收 `--ticket-file=` 参数,验证并消费一次性启动票据。 +2. 如果收到 `--health-file=` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。 + +当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了: + +- 启动票据校验。 +- 本地 License/设备身份校验。 +- 本地策略校验。 +- Manifest 完整性校验。 +- 健康标记写入。 + +如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。 + +## 如何生成 SDK 包 + +在 Windows PowerShell 中执行: + +```powershell +cd client +.\package-sdk.ps1 ` + -SourceDir .\out\bin ` + -OutputDir .\dist\UpdateClientSDK ` + -ZipFile .\dist\UpdateClientSDK.zip ` + -SdkVersion 0.1.0 +``` + +默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。 + +生成结果: + +```text +dist/UpdateClientSDK/ + SimCAE自动升级SDK接入说明_v0.1.docx + sdk_manifest.json + bin/ + config/ + app_config.json + manifest_public_key.pem + scripts/ +``` + +把 `UpdateClientSDK.zip` 发给接入方即可。 + +## 如何生成某个产品的最终客户端包 + +SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用: + +```powershell +cd client +.\package-client.ps1 ` + -SourceDir .\out\bin ` + -ConfigFile .\config\app_config.json ` + -OutputDir .\dist\UpdateClient ` + -ZipFile .\dist\UpdateClient.zip +``` + +`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。 + +## 常见错误 + +1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。 +2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。 +3. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。 +4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。 +5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。 +6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。 +7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。 diff --git a/Updater/CMakeLists.txt b/Updater/CMakeLists.txt new file mode 100644 index 0000000..167f8b6 --- /dev/null +++ b/Updater/CMakeLists.txt @@ -0,0 +1,41 @@ +# 强制所有编译、设计时生成均使用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) +# 清除32位Strawberry Perl路径干扰 +list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include") +list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib") + +project(Updater) +set(SRC + main.cpp + UpdaterLogic.h + UpdaterLogic.cpp + UpdateTransaction.h + UpdateTransaction.cpp +) +add_executable(Updater ${SRC}) + +if(WIN32) + set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE) +endif() + +# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到 +target_include_directories(Updater PRIVATE ${OPENSSL_INC}) + +# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递 +target_link_libraries(Updater PRIVATE + Common + Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets +) + +# Qt部署脚本 +if(WIN32) + get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) + get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) + set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") + add_custom_command(TARGET Updater POST_BUILD + COMMAND ${WINDEPLOYQT} $,--debug,--release> $ + ) +endif() diff --git a/Updater/UpdateTransaction.cpp b/Updater/UpdateTransaction.cpp new file mode 100644 index 0000000..d6fc305 --- /dev/null +++ b/Updater/UpdateTransaction.cpp @@ -0,0 +1,274 @@ +#include "UpdateTransaction.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, + const QString& toVersion, int manifestId) + : m_installDir(QDir::cleanPath(installDir)), + m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)), + m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")), + m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)), + m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId) +{ + m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId); + m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId); +} + +bool UpdateTransaction::safeRelativePath(const QString& path) +{ + const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); + return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." + && !clean.startsWith("../") && !clean.contains(":"); +} + +bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const +{ + if (!QDir().mkpath(QFileInfo(destination).path())) return false; + if (QFile::exists(destination) && !QFile::remove(destination)) return false; + return QFile::copy(source, destination); +} + +bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message) +{ + m_state["transaction_id"] = m_transactionId; + m_state["from_version"] = m_fromVersion; + m_state["to_version"] = m_toVersion; + m_state["status"] = status; + m_state["manifest_id"] = m_manifestId; + m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + m_state["staging_dir"] = m_stagingDir; + m_state["backup_dir"] = m_backupDir; + m_state["error_code"] = errorCode; + m_state["message"] = message; + QJsonArray paths; + for (const QString& path : m_changedPaths) paths.append(path); + m_state["changed_paths"] = paths; + QJsonArray obsoletePaths; + for (const QString& path : m_obsoletePaths) obsoletePaths.append(path); + m_state["obsolete_paths"] = obsoletePaths; + + QDir().mkpath(m_updateDir); + QSaveFile file(m_stateFile); + if (!file.open(QIODevice::WriteOnly)) return false; + const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented); + if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; } + return file.commit(); +} + +bool UpdateTransaction::initialize() +{ + QDir(m_stagingDir).removeRecursively(); + QDir(m_backupDir).removeRecursively(); + if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false; + m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + return writeState("prepared"); +} + +bool UpdateTransaction::resumeExisting(QString* errorMessage) +{ + QFile file(m_stateFile); + if (!file.open(QIODevice::ReadOnly)) { + if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; + return false; + } + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (!doc.isObject()) { + if (errorMessage) *errorMessage = "invalid upgrade_state.json"; + return false; + } + m_state = doc.object(); + const QString expectedToVersion = m_toVersion; + const int expectedManifestId = m_manifestId; + const QString stateStatus = m_state.value("status").toString(); + m_transactionId = m_state.value("transaction_id").toString(); + m_fromVersion = m_state.value("from_version").toString(); + m_toVersion = m_state.value("to_version").toString(); + m_manifestId = m_state.value("manifest_id").toInt(); + if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId + || (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify" + && stateStatus != "rollback_required")) { + if (errorMessage) *errorMessage = "upgrade state does not match resume request"; + return false; + } + m_stagingDir = m_state.value("staging_dir").toString(); + m_backupDir = m_state.value("backup_dir").toString(); + m_changedPaths.clear(); + m_obsoletePaths.clear(); + for (const QJsonValue& value : m_state.value("changed_paths").toArray()) { + const QString path = value.toString(); + if (!safeRelativePath(path)) { + if (errorMessage) *errorMessage = "unsafe path in upgrade state"; + return false; + } + m_changedPaths.append(path); + } + for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) { + const QString path = value.toString(); + if (!safeRelativePath(path)) { + if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state"; + return false; + } + m_obsoletePaths.append(path); + } + if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) { + if (errorMessage) *errorMessage = "incomplete upgrade state"; + return false; + } + return true; +} + +bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths, + const QStringList& obsoletePaths) +{ + m_changedPaths.clear(); + m_obsoletePaths.clear(); + QSet changedKeys; + for (const QString& path : changedPaths) { + if (!safeRelativePath(path)) return false; + const QString normalized = QDir::fromNativeSeparators(path); + changedKeys.insert(normalized.toCaseFolded()); + m_changedPaths.append(normalized); + } + for (const QString& path : obsoletePaths) { + if (!safeRelativePath(path)) return false; + const QString normalized = QDir::fromNativeSeparators(path); + if (changedKeys.contains(normalized.toCaseFolded())) return false; + m_obsoletePaths.append(normalized); + } + return writeState("verified"); +} + +bool UpdateTransaction::backupCurrentFiles() +{ + if (!writeState("waiting_mainapp_exit")) return false; + QStringList paths = m_changedPaths; + paths.append(m_obsoletePaths); + for (const QString& path : paths) { + const QString installed = QDir(m_installDir).filePath(path); + if (!QFile::exists(installed)) continue; + const QString backup = QDir(m_backupDir).filePath(path); + if (!copyOverwrite(installed, backup)) + return markFailed("backup_failed", path); + } + return writeState("backed_up"); +} + +bool UpdateTransaction::installStagedFiles(QString* failedPath) +{ + if (!writeState("replacing")) return false; + for (const QString& path : m_changedPaths) { + const QString source = QDir(m_stagingDir).filePath(path); + const QString destination = QDir(m_installDir).filePath(path); + if (!copyOverwrite(source, destination)) { + if (failedPath) *failedPath = path; + writeState("rollback_required", "replace_failed", path); + return false; + } + } + return writeState("replaced"); +} + +bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); } + +bool UpdateTransaction::markRollbackRequired(const QString& reason) +{ return writeState("rollback_required", "post_install_failed", reason); } + +bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); } + +bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); } + +bool UpdateTransaction::rollback(QString* failedPath) +{ + writeState("rolling_back"); + bool ok = true; + QStringList paths = m_changedPaths; + paths.append(m_obsoletePaths); + for (const QString& path : paths) { + const QString installed = QDir(m_installDir).filePath(path); + const QString backup = QDir(m_backupDir).filePath(path); + if (QFile::exists(backup)) { + if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; } + } else if (QFile::exists(installed) && !QFile::remove(installed)) { + ok = false; if (failedPath) *failedPath = path; + } + } + writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed", + failedPath ? *failedPath : QString()); + return ok; +} + +bool UpdateTransaction::commit() +{ + if (!writeState("committed")) return false; + QDir(m_stagingDir).removeRecursively(); + QDir(m_backupDir).removeRecursively(); + return true; +} + +bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message) +{ return writeState("failed", errorCode, message); } + +QString UpdateTransaction::transactionId() const { return m_transactionId; } +QString UpdateTransaction::fromVersion() const { return m_fromVersion; } +QString UpdateTransaction::stagingDir() const { return m_stagingDir; } +QString UpdateTransaction::backupDir() const { return m_backupDir; } +QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; } +QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); } + +bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir, + QString* restoredVersion, QString* errorMessage) +{ + QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir) + .filePath("upgrade_state.json"); + const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json"); + if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile)) + stateFile = legacyStateFile; + QFile file(stateFile); + if (!file.exists()) return true; + if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; } + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); + file.close(); + if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; } + QJsonObject state = doc.object(); + const QString status = state.value("status").toString(); + if (status == "committed") return true; + if (status == "rolled_back") { + if (restoredVersion) *restoredVersion = state.value("from_version").toString(); + return true; + } + const QString backupDir = state.value("backup_dir").toString(); + QJsonArray paths = state.value("changed_paths").toArray(); + for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value); + const QString errorCode = state.value("error_code").toString(); + const bool mayContainNewFiles = status == "replacing" || status == "replaced" + || status == "post_verify" || status == "rollback_required" + || status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed"; + bool ok = true; + for (const QJsonValue& value : paths) { + const QString path = value.toString(); + if (!safeRelativePath(path)) { ok = false; continue; } + const QString installed = QDir(installDir).filePath(path); + const QString backup = QDir(backupDir).filePath(path); + if (QFile::exists(backup)) { + QDir().mkpath(QFileInfo(installed).path()); + if (QFile::exists(installed)) QFile::remove(installed); + if (!QFile::copy(backup, installed)) ok = false; + } else if (mayContainNewFiles) { + if (QFile::exists(installed) && !QFile::remove(installed)) ok = false; + } + } + if (restoredVersion) *restoredVersion = state.value("from_version").toString(); + state["status"] = ok ? "rolled_back" : "failed"; + QSaveFile output(stateFile); + if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); } + if (!ok && errorMessage) *errorMessage = "failed to restore one or more files"; + return ok; +} diff --git a/Updater/UpdateTransaction.h b/Updater/UpdateTransaction.h new file mode 100644 index 0000000..9b068f1 --- /dev/null +++ b/Updater/UpdateTransaction.h @@ -0,0 +1,54 @@ +#pragma once +#include +#include +#include + +class UpdateTransaction +{ +public: + UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, + const QString& toVersion, int manifestId); + + bool initialize(); + bool resumeExisting(QString* errorMessage = nullptr); + bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths); + bool backupCurrentFiles(); + bool installStagedFiles(QString* failedPath = nullptr); + bool markAwaitingBootstrap(); + bool markRollbackRequired(const QString& reason); + bool markRolledBack(); + bool markPostVerify(); + bool rollback(QString* failedPath = nullptr); + bool commit(); + bool markFailed(const QString& errorCode, const QString& message); + + QString transactionId() const; + QString fromVersion() const; + QString stagingDir() const; + QString backupDir() const; + QStringList obsoletePaths() const; + QString healthFile() const; + + static bool recoverInterrupted(const QString& installDir, const QString& updateDir, + QString* restoredVersion = nullptr, + QString* errorMessage = nullptr); + +private: + bool writeState(const QString& status, const QString& errorCode = QString(), + const QString& message = QString()); + bool copyOverwrite(const QString& source, const QString& destination) const; + static bool safeRelativePath(const QString& path); + + QString m_installDir; + QString m_updateDir; + QString m_stateFile; + QString m_transactionId; + QString m_fromVersion; + QString m_toVersion; + int m_manifestId = 0; + QString m_stagingDir; + QString m_backupDir; + QStringList m_changedPaths; + QStringList m_obsoletePaths; + QJsonObject m_state; +}; diff --git a/Updater/UpdaterLogic.cpp b/Updater/UpdaterLogic.cpp new file mode 100644 index 0000000..98d6555 --- /dev/null +++ b/Updater/UpdaterLogic.cpp @@ -0,0 +1,751 @@ +#include "UpdaterLogic.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "ConfigHelper.h" + +#ifdef HAVE_OPENSSL +#include +#include +#include +#include +#endif + +UpdaterLogic::UpdaterLogic(QObject* parent) + : QObject(parent) +{ + m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url"); +} + +void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId) +{ + QString url = m_serverAddr + "/api/v1/update/manifest"; + QJsonObject body; + body["app_id"] = appId; + body["channel"] = channel; + body["version"] = targetVer; + body["version_id"] = versionId; + + m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) + { + qDebug() << "Manifest API returned code:" << code; + m_manifest = QJsonObject(); + m_manifestText.clear(); + + if (code == 200) + { + if (resp.contains("manifest_text") && resp.contains("manifest")) + { + m_manifestText = resp["manifest_text"].toString(); + m_manifest = resp["manifest"].toObject(); + qDebug() << "Received manifest version:" << m_manifest.value("version").toString(); + m_fileItems.clear(); + QJsonArray files = m_manifest.value("files").toArray(); + for (const QJsonValue& fileItem : files) + { + QJsonObject fileObj = fileItem.toObject(); + FileDownloadItem fi; + fi.path = fileObj.value("path").toString(); + fi.sha256 = fileObj.value("sha256").toString(); + fi.size = fileObj.value("size").toVariant().toLongLong(); + fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL + m_fileItems.append(fi); + } + } + else + { + qDebug() << "Manifest response missing fields"; + } + } + else + { + qDebug() << "Failed to get manifest"; + } + emit fetchUrlFinished(); + }); +} + +bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const +{ +#ifndef HAVE_OPENSSL + Q_UNUSED(payload); + Q_UNUSED(signatureBase64); + Q_UNUSED(publicKeyPath); + qDebug() << "OpenSSL not available, cannot verify manifest signature"; + return false; +#else + QFile keyFile(publicKeyPath); + if (!keyFile.open(QIODevice::ReadOnly)) + { + qDebug() << "Cannot open public key file:" << publicKeyPath; + return false; + } + + QByteArray keyData = keyFile.readAll(); + keyFile.close(); + + BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); + if (!bio) + { + qDebug() << "BIO_new_mem_buf failed"; + return false; + } + + EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); + BIO_free(bio); + if (!pkey) + { + qDebug() << "PEM_read_bio_PUBKEY failed"; + return false; + } + + QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8()); + EVP_MD_CTX* mdctx = EVP_MD_CTX_new(); + if (!mdctx) + { + EVP_PKEY_free(pkey); + qDebug() << "EVP_MD_CTX_new failed"; + return false; + } + + bool ok = false; + if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1) + { + if (EVP_DigestVerifyUpdate(mdctx, payload.constData(), payload.size()) == 1) + { + if (EVP_DigestVerifyFinal(mdctx, reinterpret_cast(signature.constData()), signature.size()) == 1) + { + ok = true; + } + } + } + + EVP_MD_CTX_free(mdctx); + EVP_PKEY_free(pkey); + + if (!ok) + { + qDebug() << "Manifest signature verification failed"; + } + return ok; +#endif +} + +bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const +{ + if (m_manifest.isEmpty() || m_manifestText.isEmpty()) + { + qDebug() << "No manifest available to verify"; + return false; + } + QString signature = m_manifest.value("signature").toString(); + if (signature.isEmpty()) + { + qDebug() << "Manifest signature empty"; + return false; + } + + QString path = publicKeyPath; + if (!QFile::exists(path)) + { + path = QApplication::applicationDirPath() + "/" + publicKeyPath; + } + return verifySignature(m_manifestText.toUtf8(), signature, path); +} + +bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const +{ + if (m_manifest.isEmpty()) + return false; + + QDir dir(cacheDir); + if (!dir.exists() && !dir.mkpath(".")) + return false; + + QString version = m_manifest.value("version").toString(); + QString filePath = cacheDir + "/manifest_" + version + ".json"; + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + + QJsonObject wrapper; + wrapper["manifest"] = m_manifest; + wrapper["manifest_text"] = m_manifestText; + + QJsonDocument doc(wrapper); + file.write(doc.toJson(QJsonDocument::Indented)); + file.close(); + qDebug() << "Manifest cached to" << filePath; + return true; +} + +bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version) +{ + QString filePath = cacheDir + "/manifest_" + version + ".json"; + QFile file(filePath); + if (!file.exists() || !file.open(QIODevice::ReadOnly)) + return false; + + QByteArray raw = file.readAll(); + file.close(); + QJsonDocument doc = QJsonDocument::fromJson(raw); + if (!doc.isObject()) + return false; + + QJsonObject wrapper = doc.object(); + if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text")) + return false; + + m_manifest = wrapper["manifest"].toObject(); + m_manifestText = wrapper["manifest_text"].toString(); + qDebug() << "Loaded cached manifest" << version; + return true; +} + +QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const +{ + QJsonObject copy = manifest; + copy.remove("signature"); + + auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject { + QStringList keys = obj.keys(); + std::sort(keys.begin(), keys.end(), std::less()); + QJsonObject sorted; + for (const auto& key : keys) + { + QJsonValue value = obj.value(key); + if (value.isObject()) + sorted.insert(key, self(self, value.toObject())); + else if (value.isArray()) + { + QJsonArray sortedArray; + for (const auto& element : value.toArray()) + { + if (element.isObject()) + sortedArray.append(self(self, element.toObject())); + else + sortedArray.append(element); + } + sorted.insert(key, sortedArray); + } + else + { + sorted.insert(key, value); + } + } + return sorted; + }; + + QJsonObject sorted = sortObject(sortObject, copy); + QJsonDocument doc(sorted); + return doc.toJson(QJsonDocument::Compact); +} + + +QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest) const +{ + QSet newPaths; + for (const QJsonValue& value : m_manifest.value("files").toArray()) { + const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); + 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"), + QStringLiteral("config/local_state.json"), + QStringLiteral("config/client_identity.dat"), + QStringLiteral("config/version_policy.dat") + }; + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + if (!runtimePrefix.isEmpty()) { + const QStringList runtimeProtected{ + QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"), + QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"), + QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"), + QStringLiteral("config/version_policy.dat") + }; + for (const QString& path : runtimeProtected) + protectedPaths.insert(runtimePrefix + "/" + path); + } + QStringList obsolete; + QSet seen; + for (const QJsonValue& value : oldManifest.value("files").toArray()) { + const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString()); + const QString folded = path.toCaseFolded(); + if (!isSafeRelativePath(path) || protectedPaths.contains(folded) + || newPaths.contains(folded) || seen.contains(folded)) + continue; + seen.insert(folded); + obsolete.append(path); + } + obsolete.sort(Qt::CaseInsensitive); + return obsolete; +} + +bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const +{ + if (m_manifest.isEmpty()) + return false; + + const QJsonArray files = m_manifest.value("files").toArray(); + for (const auto& item : files) + { + const QJsonObject fileObject = item.toObject(); + const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString()); + const QString expectedSha = fileObject.value("sha256").toString(); + if (!isSafeRelativePath(path)) + return false; + if (isRuntimeProtectedPath(path)) { + qDebug() << "Runtime-protected manifest entry ignored:" << path; + continue; + } + + QString fullPath = QDir(stagingDir).filePath(path); + if (!QFile::exists(fullPath) && !installedDir.isEmpty()) + fullPath = QDir(installedDir).filePath(path); + + if (!QFile::exists(fullPath)) + { + qDebug() << "Manifest file missing from staging and installation:" << path; + return false; + } + const QString actualSha = calcLocalFileSha256(fullPath); + if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0) + { + qDebug() << "File hash mismatch:" << path << actualSha << expectedSha; + return false; + } + } + qDebug() << "Full manifest validation passed using staging plus installed files"; + return true; +} + +bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir) +{ + m_offlineError.clear(); + QFile package(packagePath); + if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; } + if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; } + const QByteArray lengthBytes = package.read(8); + if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; } + quint64 headerSize = 0; + for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast(lengthBytes[i])) << (i * 8); + if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; } + QJsonParseError error; + const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error); + if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; } + const QJsonObject wrapper = wrapperDoc.object(); + const QByteArray packageText = wrapper.value("package_text").toString().toUtf8(); + const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); + const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem"; + if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; } + const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object(); + if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; } + if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; } + QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object(); + if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; } + manifest.insert("signature", wrapper.value("manifest_signature").toString()); + m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); + if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; } + if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; } + const qint64 payloadStart = 16 + qint64(headerSize); + const QJsonArray entries = packageMeta.value("files").toArray(); + for (const QJsonValue& value : entries) { + const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); + const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); + if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; } + FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi); + if (stagingDir.isEmpty()) continue; + const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; } + QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; } + QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size; + while (remaining > 0) { const QByteArray block = package.read(qMin(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); } + if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; } + } + if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; } + return true; +} + +void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) +{ + QString url = m_serverAddr + "/api/v1/update/download-url"; + QJsonObject body; + body["app_id"] = appId; + body["channel"] = channel; + body["version"] = targetVer; + body["version_id"] = versionId; + + QJsonArray emptyFiles; + body["files"] = emptyFiles; + + m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) + { + qDebug() << "Download URL API returned code:" << code; + m_fileItems.clear(); + + if (code == 200) + { + QJsonArray fileArr = resp["files"].toArray(); + for (auto item : fileArr) + { + QJsonObject obj = item.toObject(); + FileDownloadItem fi; + fi.path = obj["path"].toString(); + fi.url = obj["url"].toString(); + fi.sha256 = obj["sha256"].toString(); + fi.size = obj["size"].toVariant().toLongLong(); + m_fileItems.append(fi); + qDebug() << "File info:" << fi.path << fi.url << fi.sha256; + } + } + else + { + qDebug() << "Failed to get download URL"; + } + emit fetchUrlFinished(); + }); +} + + +QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const +{ + QFile f(filePath); + if (!f.open(QIODevice::ReadOnly)) + return ""; + QCryptographicHash hash(QCryptographicHash::Sha256); + while (!f.atEnd()) + { + QByteArray block = f.read(4096); + hash.addData(block); + } + f.close(); + return hash.result().toHex(); +} + +bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath, + const QString& expectSha256, qint64 expectedSize, + const QString& resumePartPath) +{ + const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath; + if (!QDir().mkpath(QFileInfo(partPath).path())) return false; + if (QFile::exists(savePath) + && calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) + return true; + QFile::remove(savePath); + + for (int attempt = 0; attempt < 4; ++attempt) + { + qint64 existingSize = QFileInfo(partPath).size(); + if (expectedSize >= 0 && existingSize > expectedSize) + { + QFile::remove(partPath); + existingSize = 0; + } + if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0) + { + if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) + { + QFile::remove(savePath); + return QFile::rename(partPath, savePath); + } + QFile::remove(partPath); + existingSize = 0; + } + + QFile partFile(partPath); + const QIODevice::OpenMode mode = QIODevice::WriteOnly + | (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate); + if (!partFile.open(mode)) + { + qDebug() << "Cannot open partial download:" << partPath; + return false; + } + + QNetworkAccessManager manager; + manager.setProxy(QNetworkProxy::NoProxy); + QNetworkRequest request(url); + request.setTransferTimeout(60000); + if (existingSize > 0) + request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-"); + + QNetworkReply* reply = manager.get(request); + QEventLoop loop; + bool writeOk = true; + QObject::connect(reply, &QNetworkReply::readyRead, [&]() { + const QByteArray data = reply->readAll(); + if (!data.isEmpty()) { + if (partFile.write(data) != data.size()) writeOk = false; + m_sessionDownloadedBytes += data.size(); + const qint64 received = qMin(m_downloadTotalBytes, + m_downloadCompletedBytes + partFile.size()); + const double speed = m_downloadTimer.elapsed() > 0 + ? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0; + emit downloadProgress(received, m_downloadTotalBytes, m_currentDownloadPath, speed); + } + }); + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + const QByteArray remaining = reply->readAll(); + if (!remaining.isEmpty() && partFile.write(remaining) != remaining.size()) writeOk = false; + partFile.flush(); + partFile.close(); + + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool networkOk = reply->error() == QNetworkReply::NoError; + const QString networkError = reply->errorString(); + reply->deleteLater(); + + if (existingSize > 0 && httpStatus == 200) + { + // 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。 + qDebug() << "Server ignored Range; restart full download:" << savePath; + QFile::remove(partPath); + } + else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206)) + { + const qint64 actualSize = QFileInfo(partPath).size(); + if ((expectedSize < 0 || actualSize == expectedSize) + && calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0) + { + QFile::remove(savePath); + if (QFile::rename(partPath, savePath)) + { + qDebug() << "Download completed/resumed & sha pass:" << savePath; + return true; + } + } + else if (expectedSize >= 0 && actualSize >= expectedSize) + { + qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath; + QFile::remove(partPath); + } + } + else + { + qDebug() << "Download attempt failed; partial file retained:" << attempt + 1 + << savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size(); + } + + if (attempt < 3) + { + const unsigned long delayMs = static_cast(500 * (1 << attempt)); + QElapsedTimer delay; + delay.start(); + while (delay.elapsed() < static_cast(delayMs)) + { + QApplication::processEvents(); + QThread::msleep(50); + } + } + } + return false; +} + +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"), + QStringLiteral("config/local_state.json"), + QStringLiteral("config/client_identity.dat"), + QStringLiteral("config/version_policy.dat") + }; + const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); + if (!runtimePrefix.isEmpty()) { + const QStringList runtimeProtected{ + QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"), + QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"), + QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat") + }; + for (const QString& protectedPath : runtimeProtected) + protectedPaths.insert(runtimePrefix + "/" + protectedPath); + } + return protectedPaths.contains(normalized); +} + +bool UpdaterLogic::isSafeRelativePath(const QString& path) const +{ + const QString normalized = QDir::fromNativeSeparators(path); + const QString clean = QDir::cleanPath(normalized); + return !clean.isEmpty() + && !QDir::isAbsolutePath(clean) + && clean != ".." + && !clean.startsWith("../") + && !clean.contains(":"); +} + +qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir, + const QStringList& obsoletePaths) const +{ + qint64 required = 0; + const QString cacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); + for (const auto& item : m_fileItems) { + const QString relativePath = QDir::fromNativeSeparators(item.path); + if (isRuntimeProtectedPath(relativePath)) continue; + const QString installed = QDir(targetDir).filePath(relativePath); + if (QFile::exists(installed) + && calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) == 0) + continue; + + const qint64 partialSize = QFileInfo(QDir(cacheDir).filePath( + item.sha256.toLower() + ".part")).size(); + required += qMax(0, item.size - qMin(item.size, partialSize)); + if (QFile::exists(installed)) required += QFileInfo(installed).size(); + } + for (const QString& path : obsoletePaths) { + const QString installed = QDir(targetDir).filePath(path); + if (QFile::exists(installed)) required += QFileInfo(installed).size(); + } + const qint64 reserve = qMax(128LL * 1024 * 1024, required / 10); + return required + reserve; +} + +bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir) +{ + if (m_fileItems.isEmpty()) + { + m_downloadAllOk = false; + return false; + } + + QDir stagingDir(tempDir); + if (!FileHelper::createDir(tempDir)) + return false; + const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache"); + if (!FileHelper::createDir(resumeCacheDir)) + return false; + QSet activePartialNames; + for (const auto& item : m_fileItems) + activePartialNames.insert(item.sha256.toLower() + ".part"); + QDir cacheDir(resumeCacheDir); + for (const QString& fileName : cacheDir.entryList(QStringList() << "*.part", QDir::Files)) { + if (!activePartialNames.contains(fileName.toLower())) + cacheDir.remove(fileName); + } + + m_downloadTotalBytes = 0; + m_downloadCompletedBytes = 0; + m_sessionDownloadedBytes = 0; + for (const auto& item : m_fileItems) { + const QString itemPath = QDir::fromNativeSeparators(item.path); + if (isRuntimeProtectedPath(itemPath)) continue; + const QString installed = QDir(targetDir).filePath(itemPath); + if (!QFile::exists(installed) + || calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) != 0) + m_downloadTotalBytes += item.size; + } + m_downloadTimer.restart(); + emit downloadProgress(0, m_downloadTotalBytes, QString(), 0.0); + + for (const auto& fi : m_fileItems) + { + if (!isSafeRelativePath(fi.path)) + { + qDebug() << "Unsafe relative path in manifest:" << fi.path; + m_downloadAllOk = false; + return false; + } + + const QString relativePath = QDir::fromNativeSeparators(fi.path); + if (isRuntimeProtectedPath(relativePath)) { + qDebug() << "Runtime-protected file skipped:" << relativePath; + continue; + } + const QString installedFile = QDir(targetDir).filePath(relativePath); + if (QFile::exists(installedFile) + && calcLocalFileSha256(installedFile).compare(fi.sha256, Qt::CaseInsensitive) == 0) + { + qDebug() << "Unchanged file, skip download:" << relativePath; + continue; + } + + const QString tempFile = QDir(tempDir).filePath(relativePath); + const QString parentDir = QFileInfo(tempFile).path(); + if (!QDir().mkpath(parentDir)) + { + qDebug() << "Cannot create staging subdirectory:" << parentDir; + m_downloadAllOk = false; + return false; + } + + const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part"); + m_currentDownloadPath = relativePath; + const qint64 resumedBytes = qMin(fi.size, QFileInfo(resumePartPath).size()); + const double speed = m_downloadTimer.elapsed() > 0 + ? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0; + emit downloadProgress(m_downloadCompletedBytes + resumedBytes, + m_downloadTotalBytes, m_currentDownloadPath, speed); + if (!downloadSingleFile(fi.url, tempFile, fi.sha256, fi.size, resumePartPath)) + { + m_downloadAllOk = false; + return false; + } + m_downloadCompletedBytes += fi.size; + emit downloadProgress(m_downloadCompletedBytes, m_downloadTotalBytes, + m_currentDownloadPath, speed); + } + + m_downloadAllOk = true; + return true; +} + +void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, + const QString& version, bool success) +{ + QJsonArray files; + for (const FileDownloadItem& item : m_fileItems) + files.append(QJsonObject{{"path", item.path}, {"size", item.size}}); + QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version}, + {"result", success ? "success" : "fail"}, {"files", files}}; + m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body, + [](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; }); +} + +void UpdaterLogic::reportResult(const QString& deviceId, + const QString& fromVer, + const QString& toVer, + bool success) +{ + QString url = m_serverAddr + "/api/v1/update/report"; + + QJsonObject body; + body["app_id"] = ConfigHelper::instance().getValue("App", "app_id"); + body["device_id"] = deviceId; + body["from_version"] = fromVer; + body["to_version"] = toVer; + + if (success) + body["result"] = "success"; + else + body["result"] = "fail"; + + m_http.postRequest(url, body, [](int code, const QJsonObject& resp) + { + Q_UNUSED(resp); + qDebug() << "Update result report returned code:" << code; + }); +} + +QList UpdaterLogic::getFileList() const +{ + return m_fileItems; +} diff --git a/Updater/UpdaterLogic.h b/Updater/UpdaterLogic.h new file mode 100644 index 0000000..2f6c525 --- /dev/null +++ b/Updater/UpdaterLogic.h @@ -0,0 +1,73 @@ +#pragma once +#include +#include "HttpHelper.h" +#include "FileHelper.h" +#include +#include +#include +#include +#include +#include +#include + +struct FileDownloadItem +{ + QString path; + QString url; + QString sha256; + qint64 size; +}; + +class UpdaterLogic : public QObject +{ + Q_OBJECT +public: + explicit UpdaterLogic(QObject* parent = nullptr); + + void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId); + bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const; + bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; + bool saveManifestCache(const QString& cacheDir) const; + bool loadManifestCache(const QString& cacheDir, const QString& version); + bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString()); + QString offlineError() const { return m_offlineError; } + + void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId); + void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); + void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success); + bool downloadAllFiles(const QString& tempDir, const QString& targetDir); + qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const; + QString calcLocalFileSha256(const QString& filePath) const; + + QList getFileList() const; + QJsonObject getManifest() const { return m_manifest; } + QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const; + QString getManifestText() const { return m_manifestText; } + bool allDownloadSuccess() const { return m_downloadAllOk; } + +signals: + void fetchUrlFinished(); + void downloadProgress(qint64 receivedBytes, qint64 totalBytes, + const QString& currentPath, double bytesPerSecond); + +private: + bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256, + qint64 expectedSize, const QString& resumePartPath); + bool isSafeRelativePath(const QString& path) const; + bool isRuntimeProtectedPath(const QString& path) const; + QByteArray canonicalManifestBytes(const QJsonObject& manifest) const; + bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const; + + HttpHelper m_http; + QString m_serverAddr; + QJsonObject m_manifest; + QString m_manifestText; + QList m_fileItems; + bool m_downloadAllOk = false; + qint64 m_downloadTotalBytes = 0; + qint64 m_downloadCompletedBytes = 0; + qint64 m_sessionDownloadedBytes = 0; + QString m_currentDownloadPath; + QString m_offlineError; + QElapsedTimer m_downloadTimer; +}; \ No newline at end of file diff --git a/Updater/main.cpp b/Updater/main.cpp new file mode 100644 index 0000000..fdb74b1 --- /dev/null +++ b/Updater/main.cpp @@ -0,0 +1,410 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "UpdaterLogic.h" +#include "UpdateTransaction.h" +#include "FileHelper.h" +#include "ConfigHelper.h" +#include "TicketHelper.h" +#include "PolicyHelper.h" +#include + +int main(int argc, char* argv[]) +{ + SetConsoleOutputCP(65001); + QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); + QApplication app(argc, argv); + QApplication::setApplicationName("Marsco Updater"); + + UpdaterLogic logic; + QString offlinePackagePath; + QString appId; + QString channel; + QString targetVersion; + int targetVersionId = 0; + if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { + offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); + if (!logic.loadOfflinePackage(offlinePackagePath)) { + QMessageBox::critical(nullptr, "离线包无效", logic.offlineError()); + return -1; + } + const QJsonObject packageManifest = logic.getManifest(); + appId = packageManifest.value("app_id").toString(); + channel = packageManifest.value("channel").toString(); + targetVersion = packageManifest.value("version").toString(); + targetVersionId = packageManifest.value("manifest_seq").toInt(); + } else { + if (argc < 5) { + QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。"); + return -1; + } + appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt(); + } + QString bootstrapResult; + for (int i = 5; i < argc; ++i) { + const QString arg = argv[i]; + if (arg.startsWith("--bootstrap-resume=")) + bootstrapResult = arg.mid(QString("--bootstrap-resume=").size()); + } + const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); + const QString runtimeDir = QApplication::applicationDirPath(); + + ConfigHelper& config = ConfigHelper::instance(); + const QString targetDir = config.installRoot(); + const QString updateDir = config.updateRoot(); + QDir().mkpath(updateDir); + if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) { + QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); + return -1; + } + QString fromVersion = config.getValue("App", "current_version"); + if (!offlinePackagePath.isEmpty()) { + PolicyHelper offlinePolicy(runtimeDir); + if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired() + || !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) { + QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。"); + return -1; + } + if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion), + QVersionNumber::fromString(fromVersion)) < 0 + && !offlinePolicy.allowRollback()) { + QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。"); + return -1; + } + } + QString deviceId = config.getValue("Update", "device_id"); + if (deviceId.isEmpty()) deviceId = "unknown_device"; + + if (!resumingFromBootstrap) { + QString restoredVersion; + QString recoveryError; + if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) { + QMessageBox::critical(nullptr, "更新恢复失败", + QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError)); + return -1; + } + if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) { + if (!config.setValue("App", "current_version", restoredVersion)) { + QMessageBox::critical(nullptr, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。"); + return -1; + } + fromVersion = restoredVersion; + } + } + + QProgressDialog progress("正在准备更新...", QString(), 0, 100); + progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion)); + progress.setCancelButton(nullptr); + progress.setWindowModality(Qt::ApplicationModal); + progress.setMinimumDuration(0); + progress.setAutoClose(false); + progress.setValue(2); + progress.show(); + QApplication::processEvents(); + + UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId); + const auto setProgress = [&](int value, const QString& message) { + progress.setValue(value); + progress.setLabelText(message); + QApplication::processEvents(); + }; + const auto formatBytes = [](qint64 bytes) { + const double value = static_cast(bytes); + if (bytes >= 1024LL * 1024 * 1024) + return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB"; + if (bytes >= 1024LL * 1024) + return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB"; + if (bytes >= 1024) + return QString::number(value / 1024.0, 'f', 1) + " KB"; + return QString::number(bytes) + " B"; + }; + QObject::connect(&logic, &UpdaterLogic::downloadProgress, + [&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) { + const int value = total > 0 ? 30 + static_cast(30 * received / total) : 60; + const QString fileText = path.isEmpty() ? QString("正在准备下载...") + : QString("正在下载:%1").arg(path); + progress.setValue(value); + progress.setLabelText(QString("%1\n%2 / %3 · %4/s") + .arg(fileText, formatBytes(received), formatBytes(total), + formatBytes(static_cast(bytesPerSecond)))); + QApplication::processEvents(); + }); + const auto reportUpdateResult = [&](bool success) { + if (offlinePackagePath.isEmpty()) + logic.reportResult(deviceId, fromVersion, targetVersion, success); + }; + const auto fail = [&](const QString& title, const QString& message, + const QString& errorCode = QString("update_failed")) { + transaction.markFailed(errorCode, message); + reportUpdateResult(false); + progress.close(); + 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"); + bool timeoutOk = false; + int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); + if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; + const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable); + const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable); + const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable); + const QString launchToken = config.getValue("App", "launch_token"); + const auto launchMainApp = [&](const QString& healthFile = QString()) { + QString ticketPath; + QString ticketError; + const QString launchVersion = config.getValue("App", "current_version"); + if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken, + &ticketPath, &ticketError)) { + qDebug() << "Cannot create launch ticket:" << ticketError; + return false; + } + QStringList args{QString("--ticket-file=%1").arg(ticketPath)}; + if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); + const bool started = QProcess::startDetached(mainAppPath, args); + if (!started) QFile::remove(ticketPath); + return started; + }; + const auto bootstrapPlanFile = [&]() { + return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt"); + }; + const auto launchBootstrap = [&](const QString& mode) { + const QStringList args{ + bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath, + QString::number(QCoreApplication::applicationPid()), appId, channel, + targetVersion, QString::number(targetVersionId), mode + }; + return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args); + }; + const auto delegateRollback = [&](const QString& title, const QString& reason) { + FileHelper::killProcess(mainExecutable); + transaction.markRollbackRequired(reason); + progress.setLabelText("正在将回滚工作移交给 Bootstrap..."); + QApplication::processEvents(); + if (launchBootstrap("rollback")) { + progress.close(); + return 0; + } + reportUpdateResult(false); + progress.close(); + QMessageBox::critical(nullptr, title, + reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。"); + return -1; + }; + + if (resumingFromBootstrap) { + QString resumeError; + if (!transaction.resumeExisting(&resumeError)) { + reportUpdateResult(false); + progress.close(); + QMessageBox::critical(nullptr, "事务续办失败", + QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError)); + return -1; + } + fromVersion = transaction.fromVersion(); + if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode"))) + offlinePackagePath = "__bootstrap_resumed_offline__"; + if (bootstrapResult == "rolledback") { + const bool stateOk = config.setValue("App", "current_version", fromVersion); + transaction.markRolledBack(); + reportUpdateResult(false); + if (stateOk) launchMainApp(); + progress.close(); + QMessageBox::warning(nullptr, "更新已回滚", + stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。" + : "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。"); + return stateOk ? 0 : -1; + } + if (bootstrapResult != "success") { + reportUpdateResult(false); + progress.close(); + QMessageBox::critical(nullptr, "自动回滚失败", + "Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。"); + return -1; + } + } else if (!transaction.initialize()) { + return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed"); + } else if (!offlinePackagePath.isEmpty()) { + QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode")); + if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8) + return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed"); + } + + setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包..."); + const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache"); + if (resumingFromBootstrap) { + if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) + return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。"); + } else if (offlinePackagePath.isEmpty()) { + logic.getManifest(appId, channel, targetVersion, targetVersionId); + } + if (!logic.verifyManifestSignature()) { + if (resumingFromBootstrap) + return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。"); + return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid"); + } + QStringList obsoletePaths; + if (!resumingFromBootstrap && fromVersion != targetVersion) { + UpdaterLogic oldManifestLogic; + if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion) + && oldManifestLogic.getManifest().value("version").toString() == fromVersion + && oldManifestLogic.verifyManifestSignature()) { + obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest()); + qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths; + } else { + qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety"; + } + } + if (!logic.saveManifestCache(manifestCacheDir)) { + if (resumingFromBootstrap) + return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。"); + return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed"); + } + + if (!resumingFromBootstrap) { + setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件..."); + if (offlinePackagePath.isEmpty()) + logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); + if (logic.getFileList().isEmpty()) + return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list"); + + QStorageInfo storage(updateDir); + storage.refresh(); + const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths); + const qint64 availableBytes = storage.bytesAvailable(); + if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) { + return fail("磁盘空间不足", + QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n" + "所需空间已包含下载文件、旧版本备份和安全余量。") + .arg(formatBytes(requiredBytes), formatBytes(qMax(0, availableBytes))), + "disk_space_insufficient"); + } + + const QString stagingDir = transaction.stagingDir(); + setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size())); + if (!offlinePackagePath.isEmpty()) { + if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir)) + return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid"); + } else { + if (!logic.downloadAllFiles(stagingDir, targetDir)) { + logic.reportDownloadResult(appId, channel, targetVersion, false); + return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed"); + } + logic.reportDownloadResult(appId, channel, targetVersion, true); + } + + setProgress(58, "正在校验完整版本文件..."); + if (!logic.validateLocalFiles(stagingDir, targetDir)) + return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed"); + + QStringList changedPaths; + QDir stagingRoot(stagingDir); + QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories); + while (stagingFiles.hasNext()) + changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next()))); + const QString runtimePrefix = config.runtimeRelativePath(); + const QString bootstrapManifestPath = QDir::fromNativeSeparators( + runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable); + for (const QString& path : changedPaths) { + if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0) + return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked"); + } + if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths)) + return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed"); + + setProgress(66, "正在关闭主程序..."); + if (!FileHelper::killProcess(mainExecutable)) + return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed"); + + setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...") + .arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size())); + if (!transaction.backupCurrentFiles()) + return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "backup_failed"); + + const QString planFile = bootstrapPlanFile(); + QSaveFile plan(planFile); + if (!plan.open(QIODevice::WriteOnly)) + return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed"); + const auto writePlanItem = [&](char operation, const QString& path) { + const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n'; + return plan.write(line) == line.size(); + }; + for (const QString& path : changedPaths) { + if (!writePlanItem('C', path)) { + plan.cancelWriting(); + return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "bootstrap_plan_failed"); + } + } + for (const QString& path : obsoletePaths) { + if (!writePlanItem('D', path)) { + plan.cancelWriting(); + return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed"); + } + } + if (!plan.commit() || !transaction.markAwaitingBootstrap()) + return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed"); + + setProgress(78, "正在将安装工作移交给 Bootstrap..."); + if (!launchBootstrap("install")) + return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed"); + progress.close(); + return 0; + } + + setProgress(82, "正在校验 Bootstrap 安装结果..."); + if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir)) + return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。"); + for (const QString& path : transaction.obsoletePaths()) { + if (QFile::exists(QDir(targetDir).filePath(path))) + return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path)); + } + + setProgress(89, "正在保存新版本状态..."); + if (!config.setValue("App", "current_version", targetVersion) + || config.getValue("App", "current_version") != targetVersion) + return delegateRollback("状态保存失败", "无法保存当前版本号。"); + + const QString healthFile = transaction.healthFile(); + QFile::remove(healthFile); + setProgress(94, "正在启动新版本并等待健康确认..."); + if (!launchMainApp(healthFile)) + return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable)); + + QElapsedTimer healthTimer; + healthTimer.start(); + while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) { + QApplication::processEvents(); + QThread::msleep(100); + } + if (!QFile::exists(healthFile)) + return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs)); + + setProgress(99, "正在提交更新事务..."); + if (!transaction.commit()) + return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。"); + + QFile::remove(healthFile); + QFile::remove(bootstrapPlanFile()); + reportUpdateResult(true); + progress.setValue(100); + progress.close(); + QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion)); + return 0; +} diff --git a/config/app_config.example.json b/config/app_config.example.json new file mode 100644 index 0000000..e53aa3b --- /dev/null +++ b/config/app_config.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.exe", + "launcher_executable": "Launcher.exe", + "updater_executable": "Updater.exe", + "bootstrap_executable": "Bootstrap.exe", + "health_check_timeout_ms": "15000", + "platform": "windows", + "arch": "x64" +} diff --git a/config/manifest_public_key.pem b/config/manifest_public_key.pem new file mode 100644 index 0000000..781682f --- /dev/null +++ b/config/manifest_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU +WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l +Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ +Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv +Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs +F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ +TwIDAQAB +-----END PUBLIC KEY----- diff --git a/install-sdk.ps1 b/install-sdk.ps1 new file mode 100644 index 0000000..92eb3eb --- /dev/null +++ b/install-sdk.ps1 @@ -0,0 +1,74 @@ +param( + [Parameter(Mandatory=$true)] + [string]$SdkRoot, + + [string]$ReleaseDir = (Get-Location).Path, + + [switch]$OverwriteConfig, + + [switch]$IncludeQtRuntime +) + +$ErrorActionPreference = "Stop" + +$sdk = (Resolve-Path $SdkRoot).Path +$release = (Resolve-Path $ReleaseDir).Path + +$binDir = Join-Path $sdk "bin" +$configDir = Join-Path $sdk "config" +$appConfig = Join-Path $configDir "app_config.json" +$publicKey = Join-Path $configDir "manifest_public_key.pem" + +foreach ($path in @($binDir, $appConfig, $publicKey)) { + if (-not (Test-Path $path)) { + throw "SDK file is missing: $path" + } +} + +function Test-IsQtRuntimeItem { + param([System.IO.FileSystemInfo]$Item) + + if ($IncludeQtRuntime) { + return $false + } + + if ($Item.PSIsContainer) { + return $Item.Name -in @( + "bearer", + "iconengines", + "imageformats", + "platforms", + "styles", + "translations" + ) + } + + return ( + $Item.Name -match '^Qt5.*\.dll$' -or + $Item.Name -in @( + "libEGL.dll", + "libGLESv2.dll", + "opengl32sw.dll", + "d3dcompiler_47.dll" + ) + ) +} + +Get-ChildItem $binDir -Force | Where-Object { + -not (Test-IsQtRuntimeItem $_) +} | Copy-Item -Destination $release -Recurse -Force + +$targetConfigDir = Join-Path $release "config" +New-Item $targetConfigDir -ItemType Directory -Force | Out-Null + +$targetAppConfig = Join-Path $targetConfigDir "app_config.json" +if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) { + Copy-Item $appConfig $targetAppConfig -Force +} else { + Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it." +} + +Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force + +Write-Host "SDK files installed to: $release" +Write-Host "Next: edit config/app_config.json, then start Launcher.exe." diff --git a/package-client.ps1 b/package-client.ps1 new file mode 100644 index 0000000..cc49841 --- /dev/null +++ b/package-client.ps1 @@ -0,0 +1,138 @@ +param( + [string]$SourceDir = "$PSScriptRoot/out/bin", + [Parameter(Mandatory = $true)] + [string]$ConfigFile, + [string]$OutputDir = "$PSScriptRoot/dist/UpdateClient", + [string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip" +) + +$ErrorActionPreference = "Stop" + +$source = (Resolve-Path $SourceDir).Path +$config = (Resolve-Path $ConfigFile).Path +$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json + +function Normalize-RelativePath([string]$PathValue) { + return ($PathValue -replace '\\', '/').Trim('/') +} + +function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) { + $full = (Resolve-Path $FullPath).Path + if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) { + return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/')) + } + return Normalize-RelativePath $Fallback +} + +$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json" +$configRelativeParent = Split-Path $configRelativePath -Parent +$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent) +if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" } + +function Join-RelativePath([string]$Base, [string]$Child) { + $baseNorm = Normalize-RelativePath $Base + $childNorm = Normalize-RelativePath $Child + if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm } + if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm } + return "$baseNorm/$childNorm" +} + +$requiredFields = @( + "app_id", "channel", "api_base_url", "current_version", + "client_token", "launch_token", "license_key", + "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" +) +foreach ($field in $requiredFields) { + if (-not $settings.$field) { + throw "Config file is missing required field: $field" + } +} + +$mainExecutable = Normalize-RelativePath $settings.main_executable +$launcherExecutable = Normalize-RelativePath $settings.launcher_executable +$updaterExecutable = Normalize-RelativePath $settings.updater_executable +$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable + +$requiredFiles = @( + (Join-RelativePath $runtimeDirRelative $mainExecutable), + (Join-RelativePath $runtimeDirRelative $launcherExecutable), + (Join-RelativePath $runtimeDirRelative $updaterExecutable), + (Join-RelativePath $runtimeDirRelative $bootstrapExecutable) +) +foreach ($name in $requiredFiles) { + $nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar + if (-not (Test-Path (Join-Path $source $nativeName))) { + throw "Source directory is missing required file: $name" + } +} + +$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { + $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or + $_.Extension -in @('.pdb', '.ilk') +} +if ($debugArtifacts) { + throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)" +} + +$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable +$mainLeafName = Split-Path $mainExecutable -Leaf +$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object { + $relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/')) + $relative -ne $expectedMainPath +} | Select-Object -First 1 +if ($duplicateMain) { + throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)" +} + +$manifestName = "manifest_$($settings.current_version).json" +$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName" +$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar) +if (-not (Test-Path $sourceManifest)) { + $legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName" + if (Test-Path $legacySourceManifest) { + $sourceManifest = $legacySourceManifest + } +} +if (-not (Test-Path $sourceManifest)) { + throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." +} + +if (Test-Path $OutputDir) { + Remove-Item $OutputDir -Recurse -Force +} +New-Item $OutputDir -ItemType Directory -Force | Out-Null + +Get-ChildItem $source -Force | Where-Object { + $_.Name -notin @("update", "update_temp") +} | Copy-Item -Destination $OutputDir -Recurse -Force + +$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar) +if (Test-Path $outputRuntimeUpdateDir) { + Remove-Item $outputRuntimeUpdateDir -Recurse -Force +} +$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar) +if (Test-Path $outputRuntimeTempDir) { + Remove-Item $outputRuntimeTempDir -Recurse -Force +} + +$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) +$outputConfigDir = Split-Path $outputConfigPath -Parent +New-Item $outputConfigDir -ItemType Directory -Force | Out-Null +Copy-Item $config $outputConfigPath -Force + +@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { + $runtimeFile = Join-Path $outputConfigDir $_ + if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } +} + +$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar) +New-Item $manifestDir -ItemType Directory -Force | Out-Null +Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force + +$zipParent = Split-Path $ZipFile -Parent +New-Item $zipParent -ItemType Directory -Force | Out-Null +if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } +Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal + +Write-Host "Package directory: $OutputDir" +Write-Host "ZIP file: $ZipFile" diff --git a/package-sdk.ps1 b/package-sdk.ps1 new file mode 100644 index 0000000..047d103 --- /dev/null +++ b/package-sdk.ps1 @@ -0,0 +1,116 @@ +param( + [string]$SourceDir = "$PSScriptRoot/out/bin", + [string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK", + [string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", + [string]$SdkVersion = "0.1.0", + [string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", + [switch]$IncludeDemoMainApp, + [switch]$IncludeQtRuntime +) + +$ErrorActionPreference = "Stop" + +$source = (Resolve-Path $SourceDir).Path +$exampleConfigPath = (Resolve-Path $ExampleConfig).Path + +$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") +foreach ($name in $requiredFiles) { + $path = Join-Path $source $name + if (-not (Test-Path $path)) { + throw "SDK source directory is missing required file: $path" + } +} + +$publicKeyCandidates = @( + (Join-Path $source "config/manifest_public_key.pem"), + (Join-Path $source "manifest_public_key.pem"), + (Join-Path $PSScriptRoot "config/manifest_public_key.pem") +) +$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $publicKey) { + throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." +} + +$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { + $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or + $_.Extension -in @('.pdb', '.ilk') +} +if ($debugArtifacts) { + throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)" +} + +if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force } +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 + +$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem") +if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" } + +if (-not $IncludeQtRuntime) { + $excludedTopLevel += @( + "bearer", + "iconengines", + "imageformats", + "platforms", + "styles", + "translations" + ) +} + +function Test-IsQtRuntimeFile { + param([System.IO.FileSystemInfo]$Item) + + if ($IncludeQtRuntime -or $Item.PSIsContainer) { + return $false + } + + return ( + $Item.Name -match '^Qt5.*\.dll$' -or + $Item.Name -in @( + "libEGL.dll", + "libGLESv2.dll", + "opengl32sw.dll", + "d3dcompiler_47.dll" + ) + ) +} + +Get-ChildItem $source -Force | Where-Object { + $_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_) +} | Copy-Item -Destination $binDir -Recurse -Force + +Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force +Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force + +$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { + $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" +} | Sort-Object Name | Select-Object -First 1 +if (-not $wordGuideSource) { + throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." +} +Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force + +Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force +Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force +Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force + +@{ + sdk_version = $SdkVersion + generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + sdk_type = "external-updater-runtime" + required_entry = "Launcher.exe" + contains_demo_main_app = [bool]$IncludeDemoMainApp +} | 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 +if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } +Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal + +Write-Host "SDK directory: $OutputDir" +Write-Host "SDK zip: $ZipFile" +Write-Host "SDK version: $SdkVersion" diff --git a/pdf_requirements.txt b/pdf_requirements.txt new file mode 100644 index 0000000..05dcc33 --- /dev/null +++ b/pdf_requirements.txt @@ -0,0 +1,2943 @@ +MARSCO_内部 + +软件自动升级与版本控制系统开发设计文档 + +v0.1 + +角色 + +编写 + +校对 + +校正 + +姓名 + +待填写 + +待填写 + +待填写 + +日期 + +待填写 + +待填写 + +待填写 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + + + + + + MARSCO_内部 + +修订记录 + +版本 + +日期 + +修订章节 + +修订内容 + +v0.1 + +2026-06- + +技术选型 + +明确客户端与服务端技术栈边界。 + +修订人 + +待填写 + +23 + +v0.1 + +2026-06- + +总体架构 + +梳理客户端、服务端和对象存储交互关 + +待填写 + +23 + +系。 + +v0.1 + +2026-06- + +客户端目录结 + +将目录树整理为正式表格。 + +待填写 + +23 + +构 + +v0.1 + +2026-06- + +本地配置文件 + +补充 app_config、client_identity、 + +待填写 + +23 + +设计 + +version_policy、local_state 字段说 + +明。 + +v0.1 + +2026-06- + +Manifest 设计 补充全量清单、签名和文件完整性规则。 待填写 + +23 + +v0.1 + +2026-06- + +启动票据设计 明确票据用途、校验规则和安全边界。 + +待填写 + +23 + +v0.1 + +2026-06- + +客户端启动逻 + +新增 Visio 流程图和步骤表。 + +待填写 + +23 + +辑 + +v0.1 + +2026-06- + +REST API 设计 明确设备身份接口非用户登录,补充管理 + +待填写 + +23 + +员登录仅用于后台。 + +v0.1 + +2026-06- + +数据库设计 + +补充应用、通道、版本、策略、设备、授 + +待填写 + +23 + +权和审计日志表。 + +v0.1 + +2026-06- + +对象存储目录 + +将对象存储目录整理为正式表格。 + +待填写 + +23 + +设计 + +v0.1 + +2026-06- + +Hash 校验策略 明确更新可增量、校验必须全量。 + +待填写 + +23 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +v0.1 + +2026-06- + +签名策略 + +补充签名算法、key_id 和稳定序列化要 + +待填写 + +23 + +求。 + +v0.1 + +2026-06- + +限流策略 + +将限流规则整理为表格。 + +待填写 + +23 + +v0.1 + +2026-06- + +回滚机制 + +补充事务状态机和失败恢复规则。 + +待填写 + +23 + +v0.1 + +2026-06- + +Demo 开发里程 + +按阶段整理开发计划。 + +待填写 + +23 + +碑 + +v0.1 + +2026-06- + +Demo 验收用例 补充升级、回滚、禁用、预览版、离线等 + +待填写 + +23 + +验收用例。 + +v0.1 + +2026-06- + +后续扩展方向 补充 License、灰度、多平台、日志上传 + +待填写 + +23 + +等扩展方向。 + +v0.1 + +2026-06- + +设计范围与登 + +明确本期默认可用且不做普通用户登录校 + +待填写 + +23 + +录边界 + +验;管理员登录仅用于后台。 + +v0.1 + +2026-06- + +本期范围 + +明确普通用户登录本期不做,默认可用; + +待填写 + +23 + +后续可扩展权限和授权校验。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + + MARSCO_内部 + +目录 + +1 设计范围与登录边界 ............................................................................................................. 6 + +2 技术选型 ............................................................................................................................. 6 + +2.1 客户端技术栈 ..................................................................................................................... 6 + +2.2 服务端技术栈 ..................................................................................................................... 7 + +2.3 文件存储 ............................................................................................................................ 8 + +3 总体架构 .............................................................................................................................. 8 + +4 客户端目录结构 .................................................................................................................... 9 + +5 本地配置文件设计 .............................................................................................................. 11 + +5.1 app_config.json ................................................................................................................ 11 + +5.2 client_identity.dat ............................................................................................................. 11 + +5.3 version_policy.dat ............................................................................................................ 11 + +5.4 local_state.json ................................................................................................................ 12 + +6 Manifest 设计 .................................................................................................................... 13 + +7 启动票据设计 ..................................................................................................................... 14 + +8 客户端启动逻辑 .................................................................................................................. 15 + +8.1 Launcher 启动逻辑 ........................................................................................................... 16 + +8.2 MainApp 启动逻辑 ............................................................................................................ 18 + +8.3 Updater 升级逻辑 ............................................................................................................. 20 + +9 REST API 设计 .................................................................................................................... 21 + +9.1 检查更新接口 ................................................................................................................... 21 + +9.2 获取下载链接接口 ............................................................................................................ 24 + +9.3 升级结果上报接口 ............................................................................................................ 25 + +9.4 设备身份签发接口(非用户登录) .................................................................................... 26 + +9.5 管理后台管理员登录接口(非本期普通客户端用户登录) ................................................. 27 + +9.6 管理后台接口摘要 ............................................................................................................ 28 + +10 数据库设计 ....................................................................................................................... 29 + +10.1 应用表 apps ................................................................................................................... 29 + +10.2 版本通道表 channels ...................................................................................................... 29 + +10.3 版本表 versions .............................................................................................................. 30 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +10.4 版本文件表 version_files ................................................................................................. 31 + +10.5 版本策略表 version_policies ............................................................................................ 32 + +10.6 设备表 devices ............................................................................................................... 33 + +10.7 授权表 licenses .............................................................................................................. 33 + +10.8 下载日志表 download_logs ............................................................................................. 34 + +10.9 升级日志表 upgrade_logs ................................................................................................ 35 + +10.10 管理员表 admin_users .................................................................................................. 35 + +10.11 管理员操作日志表 admin_audit_logs ............................................................................. 36 + +11 对象存储目录设计 ............................................................................................................. 37 + +12 Hash 校验策略 ................................................................................................................. 38 + +13 签名策略 .......................................................................................................................... 38 + +14 限流策略 .......................................................................................................................... 41 + +15 回滚机制 .......................................................................................................................... 42 + +16 Demo 开发里程碑 ............................................................................................................. 45 + +16.1 第一阶段:基础框架 ....................................................................................................... 45 + +16.2 第二阶段:服务端基础能力 ............................................................................................ 45 + +16.3 第三阶段:在线更新 ....................................................................................................... 45 + +16.4 第四阶段:版本策略 ....................................................................................................... 46 + +16.5 第五阶段:离线能力 ....................................................................................................... 46 + +16.6 第六阶段:可靠性与安全 ................................................................................................ 46 + +17 Demo 验收用例 ................................................................................................................ 46 + +18 后续扩展方向 ................................................................................................................... 47 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + + + MARSCO_内部 + +1 设计范围与登录边界 + +本设计文档用于说明软件自动升级与版本控制系统第一版 Demo + +的技术方案。设计范围包括客户端启动器、主程序启动校验、独立升 + +级器、版本策略、manifest、签名校验、下载授权、离线运行、回滚机 + +制、管理后台和基础服务端接口。 + +本 期 普 通 客 户 端 用 户 默 认 可 以 使 用 软 件 , 不 需 要 登 录 账 号 。 + +Launcher 启动检查、MainApp 启动校验、Updater 下载与替换、离线 + +启动、离线更新包导入、版本禁用和降级控制均不做普通用户登录校 + +验,只使用设备身份、版本策略、manifest、启动票据和签名校验。 + +本期客户端侧不实现普通用户登录。所有客户端请求使用设备身 + +份凭证进行识别,设备身份凭证不代表用户账号,也不产生用户会话。 + +后续版本可以增加普通用户登录能力,用于用户权限校验、用户 + +授权状态判断,以及判断该用户是否可以使用该软件。该能力属于后 + +续扩展,不纳入本期 Demo 必做范围。 + +后续如增加普通用户登录,建议放在 Launcher 启动检查阶段或 + +MainApp 首次进入业务前,用于用户级授权状态判断。该能力不替代 + +设备身份、版本策略、manifest、启动票据和签名校验。 + +管理后台管理员登录属于本期范围,仅用于保护后台版本发布、 + +策略修改、设备管理、离线包生成和日志查看等管理操作。该登录不 + +是普通客户端用户登录,普通客户端用户不调用管理后台管理员登录 + +接口,也不持有后台访问令牌。 +2 技术选型 +2.1 客户端技术栈 + +客户端采用 Qt + C++ 开发。 + +客户端程序包括: + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +a) Launcher.exe + +软件启动器。 + +b) MainApp.exe + +主业务程序,用于 Demo 演示主软件启动、版本显示和 DLL 加载。 + +c) Updater.exe + +独立升级器,负责下载、校验、替换、回滚和重启。 + +d) demo_plugin.dll + +Demo 插件 DLL,用于验证 DLL 替换能力。 + +e) UpdateHelper.exe + +可选辅助程序,用于后续处理 Updater 自身更新。 + +客户端推荐使用 Qt 模块: + +a) QNetworkAccessManager:HTTP API 请求和文件下载。 + +b) QJsonDocument:JSON 解析。 + +c) QFile、QDir:文件操作。 + +d) QCryptographicHash:SHA-256 hash 计算。 + +e) QProcess:启动和关闭外部进程。 + +f) QTimer:定时检查版本。 + +g) QLockFile:防止重复启动升级器。 + +h) QSaveFile:安全写入本地配置文件。 + +i) QSettings:本地配置管理。 + +j) QTemporaryDir:临时目录管理。 + +2.2 服务端技术栈 + +Demo 阶段建议使用: + +a) Python FastAPI。 + +b) SQLite。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +c) MinIO。 + +d) Redis 可选,用于限流。 + +正式阶段可选: + +a) Python FastAPI + PostgreSQL + Redis + MinIO / OSS。 + +b) Go + PostgreSQL + Redis + MinIO / OSS。 + +接口采用 HTTP REST JSON 方式约定。 + +2.3 文件存储 + +Demo 阶段使用 MinIO。 + +正式阶段可使用: + +a) 阿里云 OSS。 + +b) 腾讯云 COS。 + +c) 华为云 OBS。 + +d) AWS S3。 + +e) 私有化 MinIO。 + +f) 企业内网对象存储。 + +版本文件不建议直接存入数据库。数据库只存文件元数据、hash、 + +大小、存储路径和版本关系。 +3 总体架构 + +系统总体架构如下: + +客户端: + +a) Launcher.exe + +b) MainApp.exe + +c) Updater.exe + +d) 本地身份凭证 + +e) 本地版本策略缓存 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +f) 本地 manifest 缓存 + +g) staging 下载目录 + +h) backup 备份目录 + +i) + +logs 日志目录 + +服务端: + +a) API 服务。 + +b) 管理后台。 + +c) 数据库。 + +d) 对象存储。 + +e) Redis 限流模块。 + +f) 签名服务模块。 + +交互流程: + +a) 客户端请求服务端 API。 + +b) 服务端验证设备身份。 + +c) 服务端返回版本策略。 + +d) 客户端根据策略判断是否允许运行或升级。 + +e) 需要升级时,客户端请求下载链接。 + +f) 服务端生成短期对象存储下载 URL。 + +g) 客户端下载文件并校验。 + +h) 客户端替换文件并上报结果。 + +4 客户端目录结构 + +建议客户端目录如下: + +目录或文件 + +DemoApp/ + +Launcher.exe + +MainApp.exe + +用途 + +客户端安装根目录 + +默认启动入口,负责版本准入检查和启动票据生成 + +主业务程序 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +Updater.exe + +UpdateHelper.exe + +独立升级程序 + +可选辅助程序,用于处理 Updater 自身更新 + +plugins/demo_plugin.dll + +Demo 插件 DLL,用于验证 DLL 替换能力 + +bin/ + +config/app_config.json + +运行依赖目录 + +客户端基础配置 + +config/client_identity.dat + +本地设备身份凭证 + +config/version_policy.dat + +本地版本策略缓存 + +config/local_state.json + +本地运行状态和防回滚状态 + +update/manifest_cache/ + +manifest 缓存目录 + +update/staging/ + +update/backup/ + +update/logs/ + +data/ + +cache/ + +logs/ + +说明: + +更新文件下载临时目录 + +升级前备份目录 + +升级日志目录 + +用户数据目录,不纳入 hash 校验 + +缓存目录 + +运行日志目录 + +a) config/client_identity.dat:本地设备身份凭证。 + +b) config/version_policy.dat:本地版本策略。 + +c) update/manifest_cache:缓存 manifest。 + +d) update/staging:下载新文件的临时目录。 + +e) update/backup:升级前备份目录。 + +f) update/logs:升级日志。 + +g) plugins:插件 DLL 目录,需要进行白名单检查。 + +h) data:用户数据目录,不纳入 hash 校验。 + +i) + +logs:运行日志目录,不纳入 hash 校验。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +5 本地配置文件设计 +5.1 app_config.json + +字段 + +app_id + +app_name + +channel + +api_base_url + +platform + +arch + +current_version +5.2 client_identity.dat + +示例值 + +marsco_demo_app + +Marsco Demo App + +stable + +https://api.example.com/api/v1 + +windows + +x64 + +1.0.0 + +该文件由服务端签发,客户端只验证,不自行伪造。 + +字段 + +client_id + +device_id + +license_id + +app_id + +channel + +issued_at + +valid_until + +signature +5.3 version_policy.dat + +字段 + +app_id + +client_id + +示例值 + +client_xxx + +device_xxx + +license_xxx + +marsco_demo_app + +stable + +2026-06-23T00:00:00Z + +2026-12-31T00:00:00Z + +base64_signature + +示例值 + +marsco_demo_app + +client_xxx + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +1024 + +stable + +1.0.0 + +True + +False + +False + +True + +2026-06-23T00:00:00Z + +2026-07-23T00:00:00Z + +1.0.2 + +1.0.0 + +当前版本允许使用 + +base64_signature + +policy_seq + +channel + +current_version + +allow_run + +force_update + +allow_rollback + +offline_allowed + +issued_at + +valid_until + +latest_version + +min_supported_version + +message + +signature +5.4 local_state.json + +用于记录本地状态,防止策略回滚和系统时间回拨。 + +字段 + +max_policy_seq + +last_success_run_at + +last_online_verified_at + +last_success_version + +示例值 + +1024 + +2026-06-23T10:00:00Z + +2026-06-23T09:50:00Z + +1.0.0 + +离线时间和策略回滚判断规则: + +a) max_policy_seq 只能递增,不能被旧策略覆盖。 + +b) 新读取的 version_policy.policy_seq 小于 max_policy_seq 时,客户 + +端应拒绝启动。 + +c) 当前系统时间早于 last_success_run_at 时,视为疑似时间回拨。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +d) 当前系统时间早于 last_online_verified_at 时,视为疑似时间回拨。 + +e) 离线启动时必须同时满足:策略签名正确、policy_seq 未倒退、 + +offline_allowed 为 true、当前时间未超过 valid_until、未检测到 + +明显时间回拨。 + +f) 联网校验成功后更新 last_online_verified_at 和本地缓存策略。 + +g) 每次成功启动后更新 last_success_run_at。 + +6 Manifest 设计 + +manifest 必须是当前版本的全量受控文件清单。 + +字段 + +app_id + +version + +channel + +platform + +arch + +manifest_seq + +created_at + +files + +signature + +规则: + +示例值 + +marsco_demo_app + +1.0.2 + +stable + +windows + +x64 + +3001 + +2026-06-23T00:00:00Z + +[{"path": "Launcher.exe", "sha256": "aaa", "size": + +123456, "executable": true}, {"path": "MainApp.exe", + +"sha256": "bbb", "size": 223456, "executable": true}, + +{"path": "Updater.exe", "sha256": "ccc", "size": 323456, + +"executable": true}, {"path": "plugins/demo_plugin.dll", + +"sha256": "ddd", "size": 423456, "executable": false}] + +base64_signature + +a) manifest 中列出的文件必须存在。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +b) manifest 中列出的文件 hash 必须一致。 + +c) 受控目录下未在 manifest 中出现的 exe、dll 文件应视为异常。 + +d) manifest 必须由服务端私钥签名。 + +e) 客户端使用内置公钥验证 manifest。 + +f) manifest 应支持版本号、通道、平台、架构。 + +7 启动票据设计 + +Launcher 启动 MainApp 时生成短期启动票据。 + +推荐方式: + +a) Launcher 生成 ticket 文件。 + +b) ticket 文件存放到系统临时目录。 + +c) Launcher 启动 MainApp,并传入 ticket 文件路径。 + +d) MainApp 读取并验证 ticket。 + +e) MainApp 验证成功后删除 ticket 文件。 + +启动参数示例: + +项目 + +启动参数 + +示例 + +MainApp.exe --ticket- + +ticket 内容: + +字段 + +payload + +file=C:\Users\xxx\AppData\Local\Temp\marsco_ticket_xxx.json + +示例值 + +{"app_id": "marsco_demo_app", "client_id": + +"client_xxx", "device_id": "device_xxx", "version": + +"1.0.0", "nonce": "uuid_xxx", "issued_at": "2026-06- + +23T10:00:00Z", "expires_at": "2026-06-23T10:01:00Z"} + +signature + +base64_signature + +校验规则: + +a) 签名正确。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +b) 未过期。 + +c) app_id 一致。 + +d) client_id 一致。 + +e) device_id 一致。 + +f) version 一致。 + +g) nonce 未重复使用。 + +Demo 阶段可由 Launcher 生成本地签名票据。正式阶段需要区分 + +启动票据和服务端版本策略,真正的版本准入仍以服务端签名策略为 + +准。 + +启动票据安全边界: + +a) 启动票据只用于防止普通用户直接双击 MainApp 绕过 Launcher。 + +b) 启动票据不能替代 version_policy、manifest 和 client_identity 的 + +服务端签名校验。 + +c) 客户端不得保存服务端私钥。 + +d) Demo 阶段的本地票据签名密钥只用于本机进程间启动校验,不 + +用于签发版本策略、manifest 或设备身份。 + +e) 即使启动票据校验通过,MainApp 仍必须验证本地版本策略签名、 + +有效期、版本号和核心文件 hash。 + +8 客户端启动逻辑 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 8.1 Launcher 启动逻辑 + +MARSCO_内部 + +图 1 Launcher 启动准入流程 + +流程步骤: + +步骤 + +1 + +2 + +判断或处理 + +结果 + +启动 Launcher,读取 + +获得本地配置和设备身份 + +app_config 和 client_identity + +验证 client_identity 签名 + +身份无效时拒绝继续 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + 3 + +4 + +5 + +6 + +7 + +8 + +9 + +MARSCO_内部 + +尝试请求 /update/check + +联网成功时进入在线策略校验 + +验证服务端返回策略签名,更新 + +获得最新运行策略 + +version_policy + +联网失败时读取本地 + +进入离线策略校验 + +version_policy + +验证本地策略签名、 + +离线策略无效时拒绝启动 + +valid_until、offline_allowed + +检查本地核心文件 hash + +核心文件异常时进入更新或提示 + +判断是否允许运行 + +允许时生成启动票据并启动 + +MainApp + +不允许运行或强制升级 + +启动 Updater 或提示用户更新 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 8.2 MainApp 启动逻辑 + +MARSCO_内部 + +图 2 MainApp 启动校验流程 + +流程步骤: + +步骤 + +判断或处理 + +结果 + +1 + +2 + +3 + +启动 MainApp,检查启动参数中 + +无票据时提示通过启动器启动并 + +是否存在 ticket-file + +退出 + +读取 ticket + +获得启动票据内容 + +验证 ticket 签名和有效期 + +票据无效时拒绝启动 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + 4 + +5 + +6 + +7 + +8 + +MARSCO_内部 + +验证 version_policy + +策略无效时拒绝启动 + +验证当前版本是否允许运行 + +禁用版本或过期策略进入拦截 + +验证核心 DLL hash + +核心文件异常时拒绝启动 + +校验通过 + +进入主界面 + +主界面运行期间 + +启动定时器,定期检查更新 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +8.3 Updater 升级逻辑 + +图 3 Updater 升级与回滚流程 + +流程步骤: + +步骤 + +1 + +判断或处理 + +结果 + +启动 Updater,读取升级参数 + +获得目标版本和升级上下文 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + + 2 + +3 + +4 + +5 + +6 + +7 + +8 + +9 + +10 + +11 +9 REST API 设计 +9.1 检查更新接口 + +方法 + +POST + +接口: + +请求: + +字段 + +app_id + +client_id + +device_id + +MARSCO_内部 + +请求 /update/check,获取目标 + +获得版本清单 + +版本 manifest + +验证 manifest 签名 + +签名无效时停止升级 + +扫描本地文件并对比 manifest + +生成待下载文件列表 + +全量 hash + +请求临时下载 URL + +获得短期下载地址 + +下载文件到 staging 并校验 + +下载文件可用于替换 + +hash + +通知 MainApp 退出并等待完全退 + +进入文件替换窗口 + +出 + +备份旧文件到 backup + +形成可回滚状态 + +替换新文件并执行安装后全量校 + +校验通过后启动 MainApp + +验 + +上报升级成功 + +任一步骤失败 + +服务端记录升级结果 + +执行回滚并上报失败原因 + +路径 + +/api/v1/update/check + +示例值 + +marsco_demo_app + +client_xxx + +device_xxx + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 license_id + +current_version + +target_version + +channel + +platform + +arch + +operation + +返回: + +字段 + +allow_run + +action + +message + +current_version + +latest_version + +channel + +force_update + +allow_rollback + +policy_seq + +MARSCO_内部 + +license_xxx + +1.0.0 + +stable + +windows + +x64 + +check + +示例值 + +True + +optional_update + +发现新版本 + +1.0.0 + +1.0.2 + +stable + +False + +False + +1025 + +policy_valid_until + +2026-07-23T00:00:00Z + +manifest_xxx + +https://download.example.com/xxx + +base64_signature + +manifest_id + +manifest_url + +signature + +action 可选值: + +字段/取值 + +allow + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + MARSCO_内部 + +optional_update + +force_update + +blocked + +rollback_allowed + +rollback_denied + +license_invalid + +device_invalid + +policy_expired + +operation 可选值: + +字段/取值 + +check + +upgrade + +downgrade + +当 operation 为 downgrade 时,客户端必须传入 target_version。服务 + +端需要判断目标版本是否存在、是否属于当前通道、是否允许降级、 + +是否满足最低支持版本和数据兼容规则。 + +降级检查返回示例: + +字段 + +allow_run + +action + +message + +current_version + +latest_version + +target_version + +channel + +示例值 + +True + +rollback_allowed + +允许降级到 1.0.0 + +1.0.2 + +1.0.2 + +1.0.0 + +stable + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 force_update + +allow_rollback + +policy_seq + +MARSCO_内部 + +False + +True + +1030 + +policy_valid_until + +2026-07-23T00:00:00Z + +manifest_id + +manifest_url + +manifest_1_0_0 + +https://download.example.com/manifest-1.0.0.json + +signature +9.2 获取下载链接接口 + +base64_signature + +方法 + +POST + +接口: + +请求: + +字段 + +app_id + +client_id + +device_id + +license_id + +version + +channel + +files + +返回: + +字段 + +expires_in + +files + +路径 + +/api/v1/update/download-url + +示例值 + +marsco_demo_app + +client_xxx + +device_xxx + +license_xxx + +1.0.2 + +stable + +["MainApp.exe", "plugins/demo_plugin.dll"] + +示例值 + +600 + +[{"path": "MainApp.exe", "url": + +"https://oss.example.com/temp-url-1", "sha256": + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +"bbb", "size": 223456}, {"path": + +"plugins/demo_plugin.dll", "url": + +"https://oss.example.com/temp-url-2", "sha256": + +"ddd", "size": 423456}] + +9.3 升级结果上报接口 + +方法 + +POST + +接口: + +请求: + +字段 + +app_id + +client_id + +device_id + +license_id + +from_version + +to_version + +channel + +result + +error_code + +message + +started_at + +finished_at + +result 可选: + +字段/取值 + +success + +路径 + +/api/v1/update/report + +示例值 + +marsco_demo_app + +client_xxx + +device_xxx + +license_xxx + +1.0.0 + +1.0.2 + +stable + +success + +upgrade success + +2026-06-23T10:00:00Z + +2026-06-23T10:05:00Z + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + MARSCO_内部 + +download_failed + +hash_failed + +signature_failed + +replace_failed + +rollback_success + +rollback_failed + +permission_failed + +disk_space_not_enough + +mainapp_restart_failed +9.4 设备身份签发接口(非用户登录) + +该接口用于首次签发或刷新设备身份凭证,不是客户端用户登录 + +接口。 + +Demo 阶 段 可 不 暴 露 该 接 口 , 由 测 试 数 据 或 预 置 文 件 生 成 + +client_identity.dat。正式阶段如需启用,也应定位为设备激活/身份签 + +发能力,而不是用户名密码登录。 + +接口: + +方法 + +POST + +请求: + +字段 + +app_id + +activation_code + +device_fingerprint + +platform + +arch + +路径 + +/api/v1/device/activate + +示例值 + +marsco_demo_app + +XXXX-XXXX-XXXX + +device_hash_xxx + +windows + +x64 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 返回: + +字段 + +client_id + +device_id + +license_id + +channel + +identity_token + +MARSCO_内部 + +示例值 + +client_xxx + +device_xxx + +license_xxx + +stable + +{"valid_until": "2026-12-31T00:00:00Z", "signature": + +"base64_signature"} + +Demo 阶段可以简化为预置 client_id,不做复杂激活流程。 + +9.5 管理后台管理员登录接口(非本期普通客户端用户登录) + +该接口仅用于管理后台管理员登录,与本期普通客户端用户无关。 + +普通客户端用户在本期客户端不登录,不调用该接口,也不持有该接 + +口返回的后台访问令牌。 + +后续如增加普通用户登录,应单独设计客户端用户登录接口、用 + +户授权模型和用户权限校验逻辑,不复用管理后台管理员登录接口。 + +Demo 阶段可使用单管理员账号。正式阶段可扩展为多管理员、角 + +色权限、双因素认证或企业单点登录。 + +方法 + +POST + +接口: + +请求: + +字段 + +username + +password + +返回: + +路径 + +/api/v1/admin/login + +示例值 + +admin + +password + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +示例值 + +jwt_or_session_token + +Bearer + +7200 + +字段 + +access_token + +token_type + +expires_in + +要求: + +a) 密码不得明文存储。 + +b) 登录失败应记录审计日志。 + +c) 管理后台接口必须校验管理员 token。 + +d) Demo 阶段可不实现复杂角色权限,但必须区分未登录和已登录 + +管理员;该状态只存在于管理后台,不存在于本期普通客户端。 + +9.6 管理后台接口摘要 + +第一版 Demo 至少需要以下后台接口: + +方法 + +POST + +POST + +POST + +POST + +POST + +POST + +POST + +POST + +POST + +GET + +GET + +GET + +路径 + +/api/v1/admin/apps + +/api/v1/admin/channels + +/api/v1/admin/versions + +/api/v1/admin/versions/{version_id}/files + +/api/v1/admin/versions/{version_id}/publish + +/api/v1/admin/policies + +/api/v1/admin/policies/{policy_id}/force-update + +/api/v1/admin/policies/{policy_id}/block + +/api/v1/admin/policies/{policy_id}/rollback + +/api/v1/admin/download-logs + +/api/v1/admin/upgrade-logs + +/api/v1/admin/audit-logs + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +后台接口规则: + +a) 所有后台接口都需要管理员 token。 + +b) 发布版本时生成 manifest、计算文件 hash 并写入版本文件表。 + +c) 修改强制升级、禁用、降级策略时必须生成新的 policy_seq。 + +d) 每次关键操作必须写入管理员操作记录。 + +e) Demo 阶段可使用简化管理页面或 API 调试页面完成后台操作。 + +10 数据库设计 +10.1 应用表 apps + +字段: + +字段/取值 + +id + +app_id + +app_name + +description + +created_at + +updated_at +10.2 版本通道表 channels + +字段: + +字段/取值 + +id + +app_id + +channel_code + +channel_name + +enabled + +created_at + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +updated_at + +示例: + +字段/取值 + +stable / 正式版 + +preview / 预览版 + +internal / 内测版 + +customer_a / 客户专版 +10.3 版本表 versions + +字段: + +字段/取值 + +id + +app_id + +channel_code + +version + +version_name + +status + +is_latest + +release_notes + +offline_allowed + +offline_valid_days + +allow_rollback + +rollback_targets + +force_update + +created_at + +published_at + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +updated_at + +status 可选: + +字段/取值 + +draft + +published + +deprecated + +force_update + +blocked + +archived + +说明: + +a) allow_rollback 表示该版本是否允许作为降级目标。 + +b) rollback_targets 可为空;为空时表示不允许主动降级到其他版本。 + +c) 如果需要精确控制降级目标,rollback_targets 记录允许降级到的 + +版本列表,或拆分为独立版本关系表。 + +10.4 版本文件表 version_files + +字段: + +字段/取值 + +id + +version_id + +relative_path + +storage_key + +sha256 + +size + +file_type + +is_required + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +created_at +10.5 版本策略表 version_policies + +字段: + +字段/取值 + +id + +app_id + +channel_code + +version + +policy_seq + +allow_run + +force_update + +allow_rollback + +rollback_targets + +offline_allowed + +valid_until + +min_supported_version + +latest_version + +message + +created_at + +updated_at + +说明: + +a) 每次策略变更必须递增 policy_seq。 + +b) 客户端记录已见过的最大 policy_seq,检测到倒退时拒绝使用。 + +c) allow_rollback 表示当前策略是否允许降级。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +d) rollback_targets 用于限制允许降级的目标版本;未命中目标版本 + +时返回 rollback_denied。 + +10.6 设备表 devices + +字段: + +字段/取值 + +id + +client_id + +device_id + +license_id + +app_id + +channel_code + +status + +first_seen_at + +last_seen_at + +created_at + +updated_at + +status 可选: + +字段/取值 + +active + +disabled + +suspicious + +expired +10.7 授权表 licenses + +字段: + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +字段/取值 + +id + +license_id + +customer_name + +app_id + +channel_code + +max_devices + +valid_until + +status + +created_at + +updated_at +10.8 下载日志表 download_logs + +字段: + +字段/取值 + +id + +client_id + +device_id + +license_id + +app_id + +version + +channel_code + +file_path + +file_size + +ip + +user_agent + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +result + +created_at +10.9 升级日志表 upgrade_logs + +字段: + +字段/取值 + +id + +client_id + +device_id + +license_id + +app_id + +channel_code + +from_version + +to_version + +result + +error_code + +message + +started_at + +finished_at + +created_at +10.10 管理员表 admin_users + +字段: + +字段/取值 + +id + +username + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +password_hash + +status + +last_login_at + +created_at + +updated_at + +status 可选: + +字段/取值 + +active + +disabled + +规则: + +a) 密码只保存 hash,不保存明文。 + +b) Demo 阶段可预置一个管理员账号。 + +c) 正式阶段可扩展角色、权限、双因素认证和单点登录。 + +10.11 管理员操作日志表 admin_audit_logs + +字段: + +字段/取值 + +id + +admin_user_id + +action + +target_type + +target_id + +before_data + +after_data + +ip + +user_agent + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +created_at + +需要记录的关键操作: + +a) 登录成功和登录失败。 + +b) 创建或发布版本。 + +c) 上传版本文件。 + +d) 修改强制升级策略。 + +e) 禁用或恢复版本。 + +f) 修改降级策略。 + +g) 生成离线更新包。 + +h) 禁用或恢复设备。 + +11 对象存储目录设计 + +建议对象存储目录如下: + +对象路径 + +updates/{app_id}/ + +内容 + +应用根目录 + +updates/{app_id}/{channel}/ + +版本通道目录,例如 stable、 + +updates/{app_id}/{channel}/{version}/ + +preview + +单个版本目录 + +updates/{app_id}/{channel}/{version}/manifest.json + +当前版本 manifest + +updates/{app_id}/{channel}/{version}/manifest.sig + +manifest 签名 + +updates/{app_id}/{channel}/{version}/files/ + +版本文件目录 + +updates/{app_id}/{channel}/{version}/files/Launcher.exe + +Launcher 可执行文件 + +updates/{app_id}/{channel}/{version}/files/MainApp.exe + +主程序可执行文件 + +updates/{app_id}/{channel}/{version}/files/Updater.exe + +升级器可执行文件 + +updates/{app_id}/{channel}/{version}/files/plugins/demo_plugin.dll Demo 插件 DLL + +updates/{app_id}/{channel}/{version}/offline/ + +离线更新包目录 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +updates/{app_id}/{channel}/{version}/offline/{package}.upd + +离线更新包 + +规则: + +a) 已发布版本目录不可覆盖。 + +b) 如果要修复,应发布新版本号。 + +c) manifest 与 files 必须保持一致。 + +d) 离线包由同一套 manifest 生成。 + +e) 对象存储 bucket 正式环境应设置为私有。 + +12 Hash 校验策略 + +系统采用 SHA-256 作为文件 hash 算法。 + +规则: + +a) 服务端发布时计算所有受控文件 hash。 + +b) manifest 包含全量受控文件 hash。 + +c) 客户端升级前扫描本地文件。 + +d) 客户端只下载缺失或 hash 不一致的文件。 + +e) 下载完成后校验下载文件 hash。 + +f) 文件替换完成后执行全量 hash 校验。 + +g) 启动时执行核心文件 hash 校验。 + +h) 插件目录执行白名单检查。 + +结论: + +更新可以增量,校验必须全量。 + +13 签名策略 + +签名采用非对称签名机制。Demo 阶段建议使用 Ed25519;如果现 + +有技术栈不方便支持 Ed25519,可使用 RSA-2048 / SHA-256,但同一 + +套 Demo 中必须固定一种算法。 + +规则: + +a) 服务端保存私钥。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +b) 客户端内置公钥。 + +c) version_policy 必须签名。 + +d) manifest 必须签名。 + +e) client_identity 必须签名。 + +f) 离线更新包必须签名。 + +g) 客户端验证签名失败时拒绝启动或拒绝安装。 + +客户端不得保存服务端私钥。 + +签名数据规则: + +a) 被签名对象必须包含 signature_alg 和 key_id。 + +b) 计算签名前必须移除 signature 字段。 + +c) JSON 签名载荷必须使用稳定序列化规则:字段按字典序排序、无 + +多余空白、UTF-8 编码。 + +d) 时间字段统一使用 UTC ISO-8601 格式。 + +e) 客户端根据 key_id 选择内置公钥进行验签。 + +f) 公钥轮换时,客户端可同时内置当前公钥和上一个公钥。 + +g) 签名失败返回或记录 signature_failed。 + +version_policy 签名字段至少包括: + +字段/取值 + +app_id + +client_id + +policy_seq + +channel + +current_version + +allow_run + +force_update + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +allow_rollback + +rollback_targets + +offline_allowed + +issued_at + +valid_until + +latest_version + +min_supported_version + +message + +signature_alg + +key_id + +manifest 签名字段至少包括: + +字段/取值 + +app_id + +version + +channel + +platform + +arch + +manifest_seq + +created_at + +files + +signature_alg + +key_id + +client_identity 签名字段至少包括: + +字段/取值 + +client_id + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +device_id + +license_id + +app_id + +channel + +issued_at + +valid_until + +signature_alg + +key_id +14 限流策略 + +服务端需要支持多维度限流。 + +建议限流维度: + +a) client_id。 + +b) device_id。 + +c) license_id。 + +d) IP。 + +e) app_id。 + +f) version。 + +g) file_path。 + +建议限流规则: + +维度 + +client_id + +device_id + +license_id + +IP + +超限后返回: + +限制 + +10 分钟内最多请求 10 次下载链接 + +1 小时内最多下载 3 次完整包 + +24 小时内最多下载指定额度 + +10 分钟内最多请求 200 次 API + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +示例值 + +RATE_LIMITED + +请求过于频繁,请稍后再试。 + +字段 + +code + +message +15 回滚机制 + +升级失败时必须支持回滚。 + +流程: + +a) 下载文件到 staging。 + +b) 校验 hash。 + +c) 备份当前版本到 backup。 + +d) 替换文件。 + +e) 替换完成后执行全量校验。 + +f) 启动 MainApp 验证。 + +g) 如果失败,使用 backup 回滚。 + +h) 回滚后再次校验旧版本文件。 + +i) 上报回滚结果。 + +升级事务状态文件: + +Updater 应 在 update/ 目 录 下 维 护 事 务 状 态 文 件 , 例 如 + +update/upgrade_state.json。 + +字段: + +字段 + +transaction_id + +from_version + +to_version + +status + +manifest_id + +示例值 + +uuid_xxx + +1.0.0 + +1.0.1 + +prepared + +manifest_xxx + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +2026-06-23T10:00:00Z + +2026-06-23T10:01:00Z + +update/staging/uuid_xxx + +update/backup/uuid_xxx + +started_at + +updated_at + +staging_dir + +backup_dir + +error_code + +message + +事务状态: + +字段/取值 + +idle + +prepared + +downloading + +downloaded + +verified + +waiting_mainapp_exit + +backed_up + +replacing + +replaced + +post_verify + +committed + +rollback_required + +rolling_back + +rolled_back + +failed + +状态规则: + +a) 下载前进入 prepared。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 + + MARSCO_内部 + +b) 下载过程中进入 downloading。 + +c) 下载完成且 hash 校验通过后进入 verified。 + +d) MainApp 完全退出后才能进入 backed_up。 + +e) 备份完成后才能进入 replacing。 + +f) 替换完成后进入 post_verify 并执行全量 hash 校验。 + +g) 全量校验成功、MainApp 可启动后进入 committed。 + +h) 替换后校验失败、启动失败或异常中断时进入 rollback_required。 + +i) 回滚完成并校验旧版本成功后进入 rolled_back。 + +j) 回滚失败进入 failed,并提示用户重新安装或联系管理员。 + +异常恢复规则: + +a) Updater 启动时先读取 upgrade_state.json。 + +b) 如果状态为 downloading、downloaded 或 verified,可以清理 staging + +后重新下载。 + +c) 如果状态为 backed_up、replacing、replaced 或 post_verify,必须 + +优先尝试回滚。 + +d) 如果状态为 rollback_required 或 rolling_back,继续执行回滚。 + +e) 如果状态为 committed 或 rolled_back,可清理旧事务状态。 + +f) 清理 staging 和 backup 前必须确认事务已提交或已成功回滚。 + +需要处理的失败类型: + +a) 下载失败。 + +b) hash 失败。 + +c) 签名失败。 + +d) 文件占用。 + +e) 权限不足。 + +f) 磁盘空间不足。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +g) 替换失败。 + +h) 启动失败。 + +i) 回滚失败。 +16 Demo 开发里程碑 +16.1 第一阶段:基础框架 + +目标: + +a) 完成 Launcher、MainApp、Updater 三个 Qt 程序。 + +b) MainApp 能显示当前版本。 + +c) MainApp 能加载 demo_plugin.dll。 + +d) Launcher 能启动 MainApp。 + +e) MainApp 直接启动时能拒绝进入。 + +16.2 第二阶段:服务端基础能力 + +目标: + +a) 搭建 FastAPI 或 Go API。 + +b) 搭建 SQLite 数据库。 + +c) 搭建 MinIO。 + +d) 实现应用、版本、通道基础数据。 + +e) 实现 /update/check。 + +f) 实现 manifest 返回。 +16.3 第三阶段:在线更新 + +目标: + +a) Updater 下载 manifest。 + +b) Updater 校验 manifest 签名。 + +c) Updater 对比本地 hash。 + +d) Updater 下载差异文件。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +e) Updater 替换 MainApp 和 DLL。 + +f) Updater 自动重启 MainApp。 + +16.4 第四阶段:版本策略 + +目标: + +a) 支持强制升级。 + +b) 支持版本禁用。 + +c) 支持正式版和预览版。 + +d) 支持动态新增通道。 + +e) 支持允许降级和禁止降级。 + +16.5 第五阶段:离线能力 + +目标: + +a) 本地缓存 version_policy。 + +b) 支持离线有效期。 + +c) 离线凭证未过期时允许启动。 + +d) 离线凭证过期时禁止启动。 + +e) 支持离线更新包导入。 +16.6 第六阶段:可靠性与安全 + +目标: + +a) 支持升级失败回滚。 + +b) 支持 policy_seq 防回滚。 + +c) 支持系统时间回拨检测。 + +d) 支持插件白名单检查。 + +e) 支持下载限流。 + +f) 支持升级日志和下载日志。 + +17 Demo 验收用例 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +a) 通过 Launcher 正常启动 MainApp。 + +b) 直接启动 MainApp 被拒绝。 + +c) MainApp 显示主程序版本和 DLL 版本。 + +d) 后台发布 1.0.1。 + +e) 客户端从 1.0.0 升级到 1.0.1。 + +f) 升级后 DLL 版本变化。 + +g) 升级后 MainApp 自动重启。 + +h) 手动篡改 DLL 后启动失败。 + +i) 手动篡改 version_policy 后启动失败。 + +j) 后台设置 1.0.0 强制升级后客户端无法继续使用。 + +k) 后台禁用 1.0.0 后客户端拒绝启动。 + +l) 后台开放预览版后 preview 客户端获取 preview 更新。 + +m) 后台关闭降级通道后客户端无法降级。 + +n) 离线凭证未过期时可以启动。 + +o) 离线凭证过期后拒绝启动。 + +p) 模拟升级失败后成功回滚。 + +18 后续扩展方向 + +后续可扩展: + +a) 完整 License 系统。 + +b) 客户专版分发。 + +c) 灰度发布。 + +d) 多平台支持。 + +e) Windows 服务模式更新。 + +f) 管理员权限提权。 + +g) 代码签名证书。 + +h) 自动错误收集。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 MARSCO_内部 + +i) 客户端日志上传。 + +j) 增量二进制差分更新。 + +k) 多语言界面。 + +l) 企业内网镜像站点。 + +MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄MARSCO内部资料请勿外泄 \ No newline at end of file