Compare commits

..

15 Commits

71 changed files with 370 additions and 13588 deletions
+12 -7
View File
@@ -1,8 +1,13 @@
** **
!server/Dockerfile !update-server/
!server/requirements.txt !update-server/Dockerfile
!server/main.py !update-server/requirements.txt
!server/db.py !update-server/main.py
!server/minio_tool.py !update-server/db.py
!server/tables.sql !update-server/minio_tool.py
!server/admin.html !update-server/tables.sql
!update-server/app/
!update-server/app/**
!update-server/admin-ui/
!update-server/admin-ui/dist/
!update-server/admin-ui/dist/**
+82 -44
View File
@@ -27,12 +27,20 @@ Desktop.ini
*.rar *.rar
# Requirement / handover documents kept outside Git # Requirement / handover documents kept outside Git
client/*.pdf update-client/*.pdf
client/*.doc update-client/*.doc
client/*.docx update-client/*.docx
server/*.doc update-client/Docs/*.pdf
server/*.docx update-client/Docs/*.doc
server/*交付说明*.md update-client/Docs/*.docx
update-client/private/
update-client/pdf_requirements.txt
update-server/*.pdf
update-server/*.doc
update-server/*.docx
update-server/Docs/*.pdf
update-server/Docs/*.doc
update-server/Docs/*.docx
# C/C++ generated artifacts outside build directories # C/C++ generated artifacts outside build directories
*.obj *.obj
@@ -48,53 +56,88 @@ server/*交付说明*.md
*.exe *.exe
# Client: CMake / Visual Studio build outputs # Client: CMake / Visual Studio build outputs
client/out/ update-client/out/
client/build/ update-client/build/
client/build-*/ update-client/build-*/
client/cmake-build-*/ update-client/cmake-build-*/
client/.cmake/ update-client/.cmake/
client/CMakeFiles/ update-client/CMakeFiles/
client/CMakeCache.txt update-client/CMakeCache.txt
client/CMakeSettings.json update-client/CMakeSettings.json
client/CMakeUserPresets.json update-client/CMakeUserPresets.json
client/Testing/ update-client/Testing/
# Client: third-party/business binary drops and generated packages # Client: third-party/business binary drops and generated packages
client/App/ update-client/thirdparty/
client/dist/ update-client/App/
update-client/dist/
# Client: local runtime state and credentials # Client: local runtime state and credentials
update-client/config/app_config.json
update-client/config/client_identity.dat
update-client/config/local_state.json
update-client/config/version_policy.dat
update-client/config/*.local.json
update-client/config/*private*.pem
update-client/config/*private*.key
update-client/update/
update-client/update_temp/
# Server: Python environments and caches
update-server/venv/
update-server/.venv/
update-server/__pycache__/
update-server/**/*.py[cod]
update-server/.pytest_cache/
update-server/.mypy_cache/
update-server/.ruff_cache/
update-server/.coverage
update-server/htmlcov/
# Server: local configuration and secrets
update-server/.env
update-server/.env.*
!update-server/.env.example
!update-server/.env.docker.example
update-server/admin_token.sha256
update-server/keys/*private*.pem
update-server/keys/*private*.key
# Server: runtime databases, uploads, object storage and generated packages
update-server/*.db
update-server/*.db-journal
update-server/*.db-wal
update-server/*.db-shm
update-server/local_uploads/
update-server/upload_spool/
update-server/crash_storage/
update-server/minio_data/
update-server/runtime/
update-server/dist/
update-server/minio
# Server: runtime files
update-server/*.pid
update-server/*.log
# Docker/local generated files
.dockerignore.local
# Legacy paths before splitting into update-client/update-server submodules
client/out/
client/build/
client/App/
client/dist/
client/config/app_config.json client/config/app_config.json
client/config/client_identity.dat client/config/client_identity.dat
client/config/local_state.json client/config/local_state.json
client/config/version_policy.dat client/config/version_policy.dat
client/config/*.local.json
client/config/*private*.pem
client/config/*private*.key
client/update/
client/update_temp/
# Server: Python environments and caches
server/venv/ server/venv/
server/.venv/ server/.venv/
server/__pycache__/ server/__pycache__/
server/**/*.py[cod] server/**/*.py[cod]
server/.pytest_cache/
server/.mypy_cache/
server/.ruff_cache/
server/.coverage
server/htmlcov/
# Server: local configuration and secrets
server/.env server/.env
server/.env.* server/.env.*
!server/.env.example
!server/.env.docker.example
server/admin_token.sha256
server/keys/*private*.pem
server/keys/*private*.key
# Server: runtime databases, uploads, object storage and generated packages
server/*.db server/*.db
server/*.db-journal server/*.db-journal
server/*.db-wal server/*.db-wal
@@ -106,10 +149,5 @@ server/minio_data/
server/runtime/ server/runtime/
server/dist/ server/dist/
server/minio server/minio
# Server: runtime files
server/*.pid server/*.pid
server/*.log server/*.log
# Docker/local generated files
.dockerignore.local
+8
View File
@@ -0,0 +1,8 @@
[submodule "update-server"]
path = update-server
url = https://git.alimzs.com:6443/nikelaluo/update-server.git
branch = master
[submodule "update-client"]
path = update-client
url = https://git.alimzs.com:6443/nikelaluo/update-client.git
branch = master
-70
View File
@@ -1,70 +0,0 @@
# 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/
View File
-6
View File
@@ -1,6 +0,0 @@
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)
-191
View File
@@ -1,191 +0,0 @@
#include <windows.h>
#include <shellapi.h>
#include <filesystem>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
namespace fs = std::filesystem;
static std::wstring quote(const std::wstring& value)
{
std::wstring result = L"\"";
unsigned backslashes = 0;
for (wchar_t ch : value) {
if (ch == L'\\') { ++backslashes; continue; }
if (ch == L'\"') {
result.append(backslashes * 2 + 1, L'\\');
result.push_back(L'\"');
backslashes = 0;
continue;
}
result.append(backslashes, L'\\');
backslashes = 0;
result.push_back(ch);
}
result.append(backslashes * 2, L'\\');
result.push_back(L'\"');
return result;
}
static std::wstring fromUtf8(const std::string& value)
{
if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0);
if (size <= 0) return {};
std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), result.data(), size);
return result;
}
static bool safeRelative(const fs::path& path)
{
if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
for (const auto& part : path)
if (part == L"..") return false;
return true;
}
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
{
std::error_code ec;
fs::create_directories(destination.parent_path(), ec);
for (int i = 0; i < 100; ++i) {
ec.clear();
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec);
if (!ec) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
static bool removeWithRetry(const fs::path& path)
{
std::error_code ec;
for (int i = 0; i < 100; ++i) {
ec.clear();
if (!fs::exists(path, ec)) return !ec;
if (fs::remove(path, ec)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
static bool rollback(const fs::path& installDir, const fs::path& backupDir,
const std::vector<fs::path>& paths)
{
bool success = true;
for (const auto& relative : paths) {
const fs::path destination = installDir / relative;
const fs::path backup = backupDir / relative;
std::error_code ec;
if (fs::exists(backup, ec)) {
if (!copyWithRetry(backup, destination)) success = false;
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
success = false;
}
}
return success;
}
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
const std::wstring& result)
{
std::wstring command = quote(updater);
for (const auto& arg : updateArgs) command += L" " + quote(arg);
command += L" " + quote(L"--bootstrap-resume=" + result);
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
std::vector<wchar_t> mutableCommand(command.begin(), command.end());
mutableCommand.push_back(L'\0');
const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr,
FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi);
if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); }
return ok == TRUE;
}
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
{
int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!argv || argc < 11) {
MessageBoxW(nullptr, L"Bootstrap 参数不完整。", 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<DWORD>(_wtoi(argv[6]));
std::vector<std::wstring> updateArgs{argv[7], argv[8], argv[9], argv[10]};
const std::wstring mode = argc >= 12 ? argv[11] : L"install";
LocalFree(argv);
if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
WaitForSingleObject(process, 30000);
CloseHandle(process);
}
struct PlanItem { wchar_t operation; fs::path relative; };
std::ifstream input(planFile, std::ios::binary);
std::vector<PlanItem> items;
std::vector<fs::path> paths;
std::string line;
while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR);
return 3;
}
const fs::path relative(fromUtf8(line.substr(2)));
if (!safeRelative(relative)) {
MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR);
return 3;
}
items.push_back({static_cast<wchar_t>(line[0]), relative});
paths.push_back(relative);
}
bool success = input.eof();
bool rolledBack = false;
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;
}
-70
View File
@@ -1,70 +0,0 @@
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()
-38
View File
@@ -1,38 +0,0 @@
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
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
)
target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
INTERFACE libssl.lib libcrypto.lib
)
-181
View File
@@ -1,181 +0,0 @@
#include "ConfigHelper.h"
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
#include <QSettings>
#include <QDebug>
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;
}
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include <QString>
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;
};
-49
View File
@@ -1,49 +0,0 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QUuid>
#include <QTimer>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
const QString trimmedBase=base.trimmed();
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
ConfigHelper& config=ConfigHelper::instance();
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");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;
}
-15
View File
@@ -1,15 +0,0 @@
#pragma once
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error;
};
-42
View File
@@ -1,42 +0,0 @@
#include "FileHelper.h"
#include <QDebug>
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);
}
-14
View File
@@ -1,14 +0,0 @@
#pragma once
#include <QString>
#include <QFile>
#include <QDir>
#include <QProcess>
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);
};
-67
View File
@@ -1,67 +0,0 @@
#include "HttpHelper.h"
#include <QNetworkProxy>
#include "ConfigHelper.h"
#include <QFile>
#include <QDir>
#include <QApplication>
#include <QTimer>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> 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();
}
-17
View File
@@ -1,17 +0,0 @@
#pragma once
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonDocument>
#include <QEventLoop>
#include <QDebug>
class HttpHelper
{
public:
// 每次调用独立创建manager,不做成成员变量
static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback);
};
-153
View File
@@ -1,153 +0,0 @@
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#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<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(p);
}
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<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) m_error = "manifest RSA signature invalid";
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<QString> 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;
}
-20
View File
@@ -1,20 +0,0 @@
#pragma once
#include <QString>
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;
};
-147
View File
@@ -1,147 +0,0 @@
#include "LocalStateHelper.h"
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QDir>
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;
}
-33
View File
@@ -1,33 +0,0 @@
#pragma once
#include <QString>
#include <QJsonObject>
#include <QDateTime>
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;
};
-116
View File
@@ -1,116 +0,0 @@
#include "PolicyHelper.h"
#include <QApplication>
#include <QDateTime>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <algorithm>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#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<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
if (!ok) m_error = "Invalid RSA policy signature";
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(); }
-33
View File
@@ -1,33 +0,0 @@
#pragma once
#include <QString>
#include <QJsonObject>
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;
};
-91
View File
@@ -1,91 +0,0 @@
#include "TicketHelper.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMessageAuthenticationCode>
#include <QSaveFile>
#include <QStandardPaths>
#include <QUuid>
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;
}
-13
View File
@@ -1,13 +0,0 @@
#pragma once
#include <QString>
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);
};
-46
View File
@@ -1,46 +0,0 @@
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} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Launcher>
COMMENT "自动部署Qt依赖dll"
)
endif()
-117
View File
@@ -1,117 +0,0 @@
#include "UpdateLogic.h"
#include "ConfigHelper.h"
#include <QDebug>
#include <QJsonArray>
#include <QApplication>
#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;
});
}
-38
View File
@@ -1,38 +0,0 @@
#pragma once
#include <QObject>
#include "HttpHelper.h"
#include <QJsonObject>
#include <QString>
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;
};
-282
View File
@@ -1,282 +0,0 @@
#include <windows.h>
#include <QApplication>
#include <QDebug>
#include <QMessageBox>
#include <QProcess>
#include <QProgressDialog>
#include <QInputDialog>
#include <QLineEdit>
#include <QTextCodec>
#include "UpdateLogic.h"
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include <QFile>
#include <QFileDialog>
#include <QDir>
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;
}
-21
View File
@@ -1,21 +0,0 @@
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} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
)
endif()
-72
View File
@@ -1,72 +0,0 @@
#include "MainWindow.h"
#include <QApplication>
#include <QFont>
#include <QFile>
#include <QLabel>
#include <QVBoxLayout>
#include <QCryptographicHash>
#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);
}
-11
View File
@@ -1,11 +0,0 @@
#pragma once
#include <QWidget>
#include <QLabel>
#include <QVBoxLayout>
class MainWindow : public QWidget
{
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
};
-130
View File
@@ -1,130 +0,0 @@
#include <Windows.h>
#include <QApplication>
#include <QDebug>
#include <QTextCodec>
#include <QMessageBox>
#include <QDir>
#include <QFileInfo>
#include <QSaveFile>
#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();
}
-27
View File
@@ -1,27 +0,0 @@
客户端配置说明
==============
统一从程序目录下的 config/app_config.json 读取配置。
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
接入新软件时通常需要修改:
1. app_id、app_name、channel、current_version。
2. api_base_url、client_token、license_key、launch_token。
3. main_executable:团队业务主程序文件名。
4. launcher_executable、updater_executable、bootstrap_executable。
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
完整格式参考 config/app_config.example.json。
运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。
Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
4. 在 PowerShell 执行:
powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
-188
View File
@@ -1,188 +0,0 @@
# UpdateClientSDK 接入说明
这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。
SDK 核心程序:
- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。
- `config/app_config.json`:接入方配置。
- `config/manifest_public_key.pem`:验证服务端签名用的公钥。
## 接入方需要做什么
假设接入的软件叫 `YourApp.exe`
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`
2. 创建或确认渠道,例如 `stable`
3. 创建 License,把生成的 `license_key` 填到客户端配置。
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`
5. 把 SDK 的 `Launcher.exe``Updater.exe``Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。
6.`config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
## 安装目录写权限要求
当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如:
```text
config/app_config.json
config/client_identity.dat
config/local_state.json
config/version_policy.dat
update/
update_temp/
```
所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`
推荐联调目录:
```text
D:\SimCAE_Release\
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
```
如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。
## SimCAE 目录结构建议
SimCAE 当前安装目录是根目录下有 `bin/``Licenses/``installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录:
```text
SimCAE/
bin/
Launcher.exe
Updater.exe
Bootstrap.exe
SimCAE.exe
config/
app_config.json
manifest_public_key.pem
update/
manifest_cache/
Qt5Core.dll
...
Licenses/
installerResources/
maintenancetool.exe
```
这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root``..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll``platforms/``imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。
## app_config.json 关键字段
```json
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
```
字段说明:
- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。
- `channel`:发布渠道,例如 `stable``beta``dev`
- `current_version`:当前客户端初始版本。
- `client_protocol`:客户端协议号,当前建议为 `3`
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
## 业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了:
- 启动票据校验。
- 本地 License/设备身份校验。
- 本地策略校验。
- Manifest 完整性校验。
- 健康标记写入。
如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。
## 如何生成 SDK 包
在 Windows PowerShell 中执行:
```powershell
cd client
.\package-sdk.ps1 `
-SourceDir .\out\bin `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`
生成结果:
```text
dist/UpdateClientSDK/
SimCAE自动升级SDK接入说明_v0.1.docx
sdk_manifest.json
bin/
config/
app_config.json
manifest_public_key.pem
scripts/
```
`UpdateClientSDK.zip` 发给接入方即可。
## 如何生成某个产品的最终客户端包
SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用:
```powershell
cd client
.\package-client.ps1 `
-SourceDir .\out\bin `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。
## 常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
3. 首次启动设备登记失败:检查 `api_base_url``client_token``license_key`、服务端 License 状态。
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`
Binary file not shown.
Binary file not shown.
-41
View File
@@ -1,41 +0,0 @@
# 强制所有编译、设计时生成均使用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和QtOpenSSL库由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} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
)
endif()
-274
View File
@@ -1,274 +0,0 @@
#include "UpdateTransaction.h"
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QSaveFile>
#include <QSet>
#include <QUuid>
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<QString> 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;
}
-54
View File
@@ -1,54 +0,0 @@
#pragma once
#include <QString>
#include <QStringList>
#include <QJsonObject>
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;
};
-751
View File
@@ -1,751 +0,0 @@
#include "UpdaterLogic.h"
#include <QSet>
#include <QDebug>
#include <QFileInfo>
#include <QFile>
#include <QEventLoop>
#include <QElapsedTimer>
#include <QThread>
#include <QNetworkRequest>
#include <QNetworkProxy>
#include <QJsonArray>
#include <QCryptographicHash>
#include <QDir>
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
#include <algorithm>
#include "ConfigHelper.h"
#ifdef HAVE_OPENSSL
#include <openssl/pem.h>
#include <openssl/evp.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#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<const unsigned char*>(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<QString>());
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<QString> 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<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
for (const QString& path : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + path);
}
QStringList obsolete;
QSet<QString> seen;
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
const 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<unsigned char>(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<qint64>(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<unsigned long>(500 * (1 << attempt));
QElapsedTimer delay;
delay.start();
while (delay.elapsed() < static_cast<qint64>(delayMs))
{
QApplication::processEvents();
QThread::msleep(50);
}
}
}
return false;
}
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(normalized);
}
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<qint64>(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<qint64>(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<QString> 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<FileDownloadItem> UpdaterLogic::getFileList() const
{
return m_fileItems;
}
-73
View File
@@ -1,73 +0,0 @@
#pragma once
#include <QObject>
#include "HttpHelper.h"
#include "FileHelper.h"
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonDocument>
#include <QStringList>
#include <QNetworkReply>
#include <QMap>
#include <QElapsedTimer>
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<FileDownloadItem> 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<FileDownloadItem> 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;
};
-410
View File
@@ -1,410 +0,0 @@
#include <windows.h>
#include <QApplication>
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QDirIterator>
#include <QElapsedTimer>
#include <QFile>
#include <QMessageBox>
#include <QProcess>
#include <QProgressDialog>
#include <QSaveFile>
#include <QStorageInfo>
#include <QTextCodec>
#include <QThread>
#include "UpdaterLogic.h"
#include "UpdateTransaction.h"
#include "FileHelper.h"
#include "ConfigHelper.h"
#include "TicketHelper.h"
#include "PolicyHelper.h"
#include <QVersionNumber>
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<double>(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<int>(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<qint64>(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<qint64>(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;
}
-22
View File
@@ -1,22 +0,0 @@
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"install_root": "..",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
-9
View File
@@ -1,9 +0,0 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
TwIDAQAB
-----END PUBLIC KEY-----
-74
View File
@@ -1,74 +0,0 @@
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."
-138
View File
@@ -1,138 +0,0 @@
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"
-116
View File
@@ -1,116 +0,0 @@
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"
File diff suppressed because it is too large Load Diff
Binary file not shown.
-8
View File
@@ -1,8 +0,0 @@
**
!Dockerfile
!requirements.txt
!main.py
!db.py
!minio_tool.py
!tables.sql
!admin.html
-78
View File
@@ -1,78 +0,0 @@
# Docker 镜像名称。通常不用改;重新打镜像并改版本号时才需要同步修改。
SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0
# 服务端端口。浏览器访问 http://服务器IP:8000/。
SERVER_PORT=8000
# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。
APP_UID=1000
APP_GID=1000
# 管理后台标题。可不填或不改。
SERVICE_TITLE=SimCAE Update Service
# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。
CORS_ALLOW_ORIGINS=*
# 发布包目标平台。当前客户端是 Windows x64,通常不用改。
TARGET_PLATFORM=windows
TARGET_ARCH=x64
# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。
SIGNING_KEY_ID=manifest-key-v1
# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。
# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。
PUBLISH_MAX_REQUEST_MB=4096
PUBLISH_MAX_FILES=20000
# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。
PUBLISH_MAX_FIELDS=20100
# 上传时额外保留的磁盘空间,单位 MB。通常不用改。
UPLOAD_SPACE_RESERVE_MB=256
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
CLIENT_API_TOKEN=SimCAEClientToken2026
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。
ADMIN_TOKEN=SimCAEAdminToken2026
# 崩溃报告接口。已给默认值,可直接试跑;正式部署建议修改。
CRASH_SERVICE_VERSION=1.0.0
CRASH_REPORT_TOKEN=SimCAECrashReportToken2026
CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026
# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。
CRASH_ADMIN_TOKEN=
# 崩溃报告大小限制。通常不用改。
CRASH_METADATA_MAX_KB=256
CRASH_MINIDUMP_MAX_MB=128
CRASH_ATTACHMENTS_MAX_MB=64
CRASH_REQUEST_MAX_MB=200
CRASH_SYMBOLS_MAX_MB=512
# MinIO 端口。通常不用改;如果端口被占用再改。
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
# MinIO 用户名和密码。Docker 启动 MinIO 时会用这里的值初始化账号;已给默认值,可直接试跑,正式部署建议修改。
MINIO_ACCESS_KEY=simcae_minio_admin
MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe
# MinIO 存储桶名称。通常不用改。
MINIO_BUCKET=updates
# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
# MinIO 预签名下载链接有效期,单位分钟。通常不用改。
SIGN_EXPIRE_MIN=60
# MinIO 连接超时和重试参数。通常不用改。
MINIO_CONNECT_TIMEOUT_SEC=2
MINIO_READ_TIMEOUT_SEC=5
MINIO_RETRY_TOTAL=1
MINIO_HEALTH_TIMEOUT_SEC=2
-82
View File
@@ -1,82 +0,0 @@
# 服务监听地址和端口。本机开发一般保持 0.0.0.0:8000。
SERVER_HOST=0.0.0.0
SERVER_PORT=8000
# 本地开发时一般不用改;Docker 部署主要看 .env.docker.example。
APP_UID=1000
APP_GID=1000
SERVICE_TITLE=Software Update Service
ADMIN_HTML_PATH=admin.html
CORS_ALLOW_ORIGINS=*
TARGET_PLATFORM=windows
TARGET_ARCH=x64
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
SIGNING_KEY_ID=manifest-key-v1
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
CLIENT_API_TOKEN=SimCAEClientToken2026
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。
ADMIN_TOKEN=SimCAEAdminToken2026
# 数据库和 Manifest 私钥路径。本地开发一般不用改。
DB_FILE=mini.db
SQL_FILE=tables.sql
MANIFEST_PRIVATE_KEY_PATH=keys/manifest_private_key.pem
# 上传暂存和本地兜底存储目录。通常不用改。
UPLOAD_SPOOL_DIR=upload_spool
UPLOAD_SPACE_RESERVE_MB=256
# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。
# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。
PUBLISH_MAX_REQUEST_MB=4096
PUBLISH_MAX_FILES=20000
# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。
PUBLISH_MAX_FIELDS=20100
LOCAL_UPLOAD_ROOT=local_uploads
LOCAL_FILE_URL_BASE=http://127.0.0.1:8000/static
# SimCAE 崩溃报告接口。接入崩溃上报时必须改;不接入崩溃上报也建议改成随机字符串。
CRASH_SERVICE_VERSION=1.0.0
CRASH_REPORT_TOKEN=SimCAECrashReportToken2026
CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026
# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。
CRASH_ADMIN_TOKEN=
CRASH_STORAGE_ROOT=crash_storage
# 崩溃报告大小限制。通常不用改。
CRASH_METADATA_MAX_KB=256
CRASH_MINIDUMP_MAX_MB=128
CRASH_ATTACHMENTS_MAX_MB=64
CRASH_REQUEST_MAX_MB=200
CRASH_SYMBOLS_MAX_MB=512
# MinIO 地址。本地开发如果 MinIO 在本机 9000 端口,保持不变。
MINIO_ENDPOINT=127.0.0.1:9000
# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
# MinIO 用户名、密码和桶。必须和你的 MinIO 配置一致。
MINIO_ACCESS_KEY=simcae_minio_admin
MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe
MINIO_BUCKET=updates
MINIO_SECURE=false
# MinIO 预签名下载链接和连接参数。通常不用改。
SIGN_EXPIRE_MIN=60
MINIO_CONNECT_TIMEOUT_SEC=2
MINIO_READ_TIMEOUT_SEC=5
MINIO_RETRY_TOTAL=1
MINIO_HEALTH_TIMEOUT_SEC=2
# 本地演示便利项。Docker 部署时会强制关闭自动启动 MinIO。
MINIO_AUTO_START=true
MINIO_AUTO_DOWNLOAD=false
MINIO_BIN_PATH=minio
MINIO_DATA_DIR=minio_data
MINIO_LISTEN_ADDRESS=:9000
MINIO_START_WAIT_SEC=5
MINIO_DOWNLOAD_URL=https://dl.min.io/server/minio/release/linux-amd64/minio
-59
View File
@@ -1,59 +0,0 @@
# Operating systems and editors
.DS_Store
Thumbs.db
Desktop.ini
.vscode/
.idea/
# Temporary files
*.tmp
*.temp
*.bak
*.swp
*.log
*~
# Private requirement / handover documents
*.doc
*.docx
*交付说明*.md
# Python environments and caches
venv/
.venv/
__pycache__/
**/__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
# Local configuration and secrets
.env
.env.*
!.env.example
!.env.docker.example
admin_token.sha256
keys/*private*.pem
keys/*private*.key
# Runtime databases, uploads, object storage and generated packages
*.db
*.db-journal
*.db-wal
*.db-shm
local_uploads/
upload_spool/
crash_storage/
minio_data/
runtime/
dist/
minio
# Runtime files
*.pid
# Docker/local generated files
.dockerignore.local
-30
View File
@@ -1,30 +0,0 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp
RUN apt-get update \
&& apt-get install -y --no-install-recommends libarchive-tools \
&& rm -rf /var/lib/apt/lists/*
COPY server/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./
COPY --chown=updateapp:updateapp server/admin.html ./admin.html
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
&& chown -R updateapp:updateapp /app /data \
&& chmod 644 /app/admin.html
USER updateapp
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"
CMD ["python", "main.py"]
-2428
View File
File diff suppressed because it is too large Load Diff
-38
View File
@@ -1,38 +0,0 @@
import sqlite3
from pathlib import Path
import os
from dotenv import load_dotenv
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
def configured_path(name: str, default: str) -> Path:
path = Path(os.getenv(name, default)).expanduser()
return path if path.is_absolute() else BASE_DIR / path
DB_FILE = configured_path("DB_FILE", "mini.db")
SQL_FILE = configured_path("SQL_FILE", "tables.sql")
# 初始化数据库
def init_db():
conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
cur = conn.cursor()
sql = Path(SQL_FILE).read_text("utf-8")
cur.executescript(sql)
conn.commit()
conn.close()
print(f"数据库 {DB_FILE} 创建完成")
# 获取连接
def get_conn():
conn = sqlite3.connect(DB_FILE, timeout=30, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys=ON")
return conn
if __name__ == "__main__":
init_db()
-75
View File
@@ -1,75 +0,0 @@
services:
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- ./minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
minio-init:
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
depends_on:
minio:
condition: service_healthy
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- >-
mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
mc mb --ignore-existing "update-store/$${MINIO_BUCKET}"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
api:
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
restart: unless-stopped
user: "${APP_UID:-1000}:${APP_GID:-1000}"
env_file:
- .env
environment:
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8000
ENV_FILE_PATH: /app/.env
DB_FILE: /data/mini.db
SQL_FILE: /app/tables.sql
ADMIN_HTML_PATH: /app/admin.html
LOCAL_UPLOAD_ROOT: /data/uploads
UPLOAD_SPOOL_DIR: /data/upload_spool
MINIO_DATA_DIR: /minio_data
CRASH_STORAGE_ROOT: /data/crash_storage
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
MINIO_ENDPOINT: minio:9000
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required}
MINIO_AUTO_START: "false"
MINIO_SECURE: "false"
ports:
- "${SERVER_PORT:-8000}:8000"
volumes:
- ./.env:/app/.env
- ./runtime:/data
- ./minio_data:/minio_data:ro
- ./keys:/run/secrets/update-keys:ro
depends_on:
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
interval: 15s
timeout: 3s
start_period: 10s
retries: 3
-77
View File
@@ -1,77 +0,0 @@
services:
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
command: server /data --console-address ":9001"
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- ./minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
minio-init:
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
depends_on:
minio:
condition: service_healthy
restart: "no"
entrypoint: ["/bin/sh", "-c"]
command:
- >-
mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
mc mb --ignore-existing "update-store/$${MINIO_BUCKET}"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
api:
build:
context: ..
dockerfile: server/Dockerfile
restart: unless-stopped
user: "${APP_UID:-1000}:${APP_GID:-1000}"
env_file:
- .env
environment:
SERVER_HOST: 0.0.0.0
SERVER_PORT: 8000
ENV_FILE_PATH: /app/.env
DB_FILE: /data/mini.db
SQL_FILE: /app/tables.sql
ADMIN_HTML_PATH: /app/admin.html
LOCAL_UPLOAD_ROOT: /data/uploads
UPLOAD_SPOOL_DIR: /data/upload_spool
MINIO_DATA_DIR: /minio_data
CRASH_STORAGE_ROOT: /data/crash_storage
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
MINIO_ENDPOINT: minio:9000
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required}
MINIO_AUTO_START: "false"
MINIO_SECURE: "false"
ports:
- "${SERVER_PORT:-8000}:8000"
volumes:
- ./.env:/app/.env
- ./runtime:/data
- ./minio_data:/minio_data:ro
- ./keys:/run/secrets/update-keys:ro
depends_on:
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
interval: 15s
timeout: 3s
start_period: 10s
retries: 3
-9
View File
@@ -1,9 +0,0 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
TwIDAQAB
-----END PUBLIC KEY-----
-2244
View File
File diff suppressed because it is too large Load Diff
-168
View File
@@ -1,168 +0,0 @@
from minio import Minio
from datetime import timedelta
import os
from dotenv import load_dotenv
import io
import socket
import shutil
from pathlib import Path
import urllib3
from urllib.parse import urlparse
from urllib3.util import Retry, Timeout
load_dotenv()
BASE_DIR = Path(__file__).resolve().parent
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"}
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
MINIO_BUCKET = os.getenv("MINIO_BUCKET")
SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60)
LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")).expanduser()
if not LOCAL_UPLOAD_ROOT.is_absolute():
LOCAL_UPLOAD_ROOT = BASE_DIR / LOCAL_UPLOAD_ROOT
LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE")
MINIO_SECURE = env_bool("MINIO_SECURE")
MINIO_CONNECT_TIMEOUT_SEC = float(os.getenv("MINIO_CONNECT_TIMEOUT_SEC", "2"))
MINIO_READ_TIMEOUT_SEC = float(os.getenv("MINIO_READ_TIMEOUT_SEC", "5"))
MINIO_RETRY_TOTAL = int(os.getenv("MINIO_RETRY_TOTAL", "1"))
LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
http_client = urllib3.PoolManager(
timeout=Timeout(connect=MINIO_CONNECT_TIMEOUT_SEC, read=MINIO_READ_TIMEOUT_SEC),
retries=Retry(total=MINIO_RETRY_TOTAL, connect=MINIO_RETRY_TOTAL,
read=MINIO_RETRY_TOTAL, status=MINIO_RETRY_TOTAL, backoff_factor=0.2),
)
mc = Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_SECURE,
http_client=http_client,
)
public_endpoint_raw = os.getenv("MINIO_PUBLIC_ENDPOINT", "").strip()
if public_endpoint_raw:
parsed_public_endpoint = urlparse(
public_endpoint_raw if "://" in public_endpoint_raw else f"http://{public_endpoint_raw}"
)
public_mc = Minio(
parsed_public_endpoint.netloc,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=parsed_public_endpoint.scheme == "https",
)
else:
public_mc = mc
def get_url(file_path: str) -> str:
bucket = MINIO_BUCKET
full_object_path = file_path
try:
mc.stat_object(bucket, full_object_path)
url = public_mc.presigned_get_object(
bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN)
)
return url
except Exception as err:
print(f"generate url error: {err}")
local_path = LOCAL_UPLOAD_ROOT / file_path
if local_path.exists():
if LOCAL_FILE_URL_BASE:
return LOCAL_FILE_URL_BASE.rstrip('/') + '/' + file_path
host = os.getenv('SERVER_HOST') or '127.0.0.1'
if host == '0.0.0.0':
try:
host = socket.gethostbyname(socket.gethostname())
except Exception:
host = '127.0.0.1'
port = os.getenv('SERVER_PORT') or '8000'
return f"http://{host}:{port}/static/{file_path}"
scheme = "https" if MINIO_SECURE else "http"
return f"{scheme}://{MINIO_ENDPOINT}/{bucket}/{full_object_path}"
def put_file(object_path: str, data: bytes, size: int):
bucket = MINIO_BUCKET
try:
bio = io.BytesIO(data)
mc.put_object(bucket, object_path, bio, length=size)
return {'storage': 'minio', 'path': object_path}
except Exception as e:
print(f"minio put_file error: {e}")
local_path = LOCAL_UPLOAD_ROOT / object_path
local_path.parent.mkdir(parents=True, exist_ok=True)
with open(local_path, 'wb') as f:
f.write(data)
print(f"local fallback stored {local_path}")
return {'storage': 'local', 'path': str(local_path)}
def put_file_stream(object_path: str, source, size: int):
bucket = MINIO_BUCKET
try:
source.seek(0)
mc.put_object(bucket, object_path, source, length=size)
return {'storage': 'minio', 'path': object_path}
except Exception as e:
print(f"minio put_file_stream error: {e}")
local_path = LOCAL_UPLOAD_ROOT / object_path
local_path.parent.mkdir(parents=True, exist_ok=True)
source.seek(0)
with open(local_path, 'wb') as target:
shutil.copyfileobj(source, target, length=1024 * 1024)
print(f"local fallback stored {local_path}")
return {'storage': 'local', 'path': str(local_path)}
def remove_prefix(prefix: str):
bucket = MINIO_BUCKET
try:
objects = mc.list_objects(bucket, prefix=prefix, recursive=True)
for obj in objects:
try:
mc.remove_object(bucket, obj.object_name)
except Exception as e:
print(f"failed remove object {obj.object_name}: {e}")
except Exception as e:
print(f"minio remove_prefix error: {e}")
local_prefix = LOCAL_UPLOAD_ROOT / prefix
if local_prefix.exists():
try:
shutil.rmtree(local_prefix)
except Exception as e:
print(f"local remove_prefix error: {e}")
def iter_file(object_path: str, chunk_size: int = 1024 * 1024):
"""Stream an object without creating a second full local copy."""
response = None
emitted = 0
try:
response = mc.get_object(MINIO_BUCKET, object_path)
while True:
chunk = response.read(chunk_size)
if not chunk: break
emitted += len(chunk)
yield chunk
return
except Exception as err:
if emitted:
raise RuntimeError(f"MinIO stream interrupted after {emitted} bytes: {object_path}") from err
print(f"MinIO stream fallback for {object_path}: {err}")
finally:
if response is not None:
response.close(); response.release_conn()
local_path = LOCAL_UPLOAD_ROOT / object_path
with open(local_path, "rb") as source:
while True:
chunk = source.read(chunk_size)
if not chunk: break
yield chunk
-511
View File
@@ -1,511 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="0.1.0"
OUTPUT_DIR=""
SKIP_BUILD=0
SKIP_PULL=0
usage() {
cat <<'EOF'
Usage: ./package-offline-server.sh [options]
Options:
--version VERSION Image/package version, default: 0.1.0
--output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage
--skip-build Do not build simcae-update-server image
--skip-pull Do not pull MinIO images
-h, --help Show this help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
VERSION="${2:?--version requires a value}"
shift 2
;;
--output-dir)
OUTPUT_DIR="${2:?--output-dir requires a value}"
shift 2
;;
--skip-build)
SKIP_BUILD=1
shift
;;
--skip-pull)
SKIP_PULL=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
require_file() {
local path="$1"
local message="$2"
if [[ ! -e "$path" ]]; then
echo "$message" >&2
exit 1
fi
}
require_cmd() {
local name="$1"
if ! command -v "$name" >/dev/null 2>&1; then
echo "Required command is missing: $name" >&2
exit 1
fi
}
require_cmd docker
require_cmd tar
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
if [[ -z "$OUTPUT_DIR" ]]; then
OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage"
fi
IMAGE_NAME="simcae-update-server:$VERSION"
MINIO_IMAGE="minio/minio:RELEASE.2025-04-22T22-12-26Z"
MC_IMAGE="minio/mc:RELEASE.2025-04-16T18-13-26Z"
PACKAGE_DIR="$OUTPUT_DIR"
IMAGES_DIR="$PACKAGE_DIR/images"
KEYS_DIR="$PACKAGE_DIR/keys"
TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar"
ARCHIVE_FILE="$PACKAGE_DIR.tar.gz"
require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing."
require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing."
require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing."
require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing."
require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing."
if [[ "$SKIP_BUILD" -eq 0 ]]; then
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT"
fi
if [[ "$SKIP_PULL" -eq 0 ]]; then
docker pull "$MINIO_IMAGE"
docker pull "$MC_IMAGE"
fi
rm -rf "$PACKAGE_DIR"
mkdir -p "$IMAGES_DIR" "$KEYS_DIR"
cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example"
cp "$SCRIPT_DIR/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem"
cp "$SCRIPT_DIR/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem"
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
# SimCAE 服务端 Docker 部署包说明
这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。
如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。
## 一、这个包里面有什么
解压后目录结构如下:
```text
SimCAEServerDockerPackage/
README.md 当前说明文档
docker-compose.yml 容器编排文件
.env.example 环境变量模板
load-images.sh 导入 Docker 镜像并创建 .env 的脚本
images/
simcae-server-all-images___VERSION__.tar Docker 镜像包,包含 api、minio、minio-init
keys/
manifest_private_key.pem 服务端 Manifest 签名私钥
manifest_public_key.pem 与客户端配套的验签公钥
```
这些文件的关系可以这样理解:
```text
images/*.tar 程序镜像包,相当于安装材料
docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载
.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成
keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签
runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等
minio_data/ 启动后自动生成,保存升级包文件
```
## 二、最小部署流程
先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行:
```bash
tar -xzf SimCAEServerDockerPackage.tar.gz
cd SimCAEServerDockerPackage
pwd
ls
```
你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到:
```text
docker-compose.yml
.env.example
load-images.sh
images/
keys/
```
后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。
继续在 `SimCAEServerDockerPackage` 目录执行:
```bash
bash ./load-images.sh
```
这个脚本会做两件事:
```text
1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像
2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env
3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限
```
然后修改 `.env`
```bash
nano .env
```
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址:
```text
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
```
例如服务器 IP 是 `192.168.229.128`
```text
MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000
```
启动服务:
```bash
docker compose up -d
```
查看状态和后端日志:
```bash
docker compose ps
docker compose logs -f api
```
浏览器访问:
```text
后台/API: http://你的服务器IP:8000/
MinIO 控制台: http://你的服务器IP:9001/
```
如果服务器开启防火墙,至少放行:
```text
8000 后台/API
9000 客户端下载升级文件
9001 MinIO 控制台,可选
```
## 三、.env 配置怎么填
`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。
### 必须确认或修改
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
| --- | --- | --- | --- |
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 |
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
### 通常不用改
| 字段 | 作用 | 默认值说明 |
| --- | --- | --- |
| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:__VERSION__`。 |
| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 |
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 |
| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 |
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 |
### 发布保护参数
这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。
| 字段 | 作用 | 默认值 |
| --- | --- | --- |
| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` |
| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` |
| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` |
| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` |
如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。
### 崩溃报告接口参数
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
| --- | --- | --- | --- |
| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 |
| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 |
| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 |
| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 |
| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 |
| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 |
| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 |
| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 |
| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 |
## 四、客户端配置要同步哪些值
客户端 `config/app_config.json` 至少要和服务端保持这两个值一致:
```json
{
"api_base_url": "http://你的服务器IP:8000",
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
}
```
如果服务端 `.env` 里保持默认:
```text
CLIENT_API_TOKEN=SimCAEClientToken2026
```
客户端就填:
```json
"client_token": "SimCAEClientToken2026"
```
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
## 五、发布新版本
进入后台后,“发布新版本”支持两种方式:
```text
1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
```
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是:
```text
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
```
`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
## 六、常用命令
确认你仍然在 `SimCAEServerDockerPackage` 目录:
```bash
pwd
```
启动:
```bash
docker compose up -d
```
查看状态:
```bash
docker compose ps
```
查看后端日志:
```bash
docker compose logs -f api
```
查看所有容器日志:
```bash
docker compose logs -f
```
重启后端:
```bash
docker compose restart api
```
停止服务:
```bash
docker compose down
```
如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。
## 七、数据、备份和迁移
运行后会生成这些目录或文件:
```text
.env 当前服务器实际配置
runtime/ 服务端数据库、上传临时目录、崩溃报告等
minio_data/ MinIO 对象数据,也就是上传后的升级文件
keys/ Manifest 签名密钥
```
备份或迁移正式环境时,重点备份:
```text
.env
runtime/
minio_data/
keys/
docker-compose.yml
```
不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。
## 八、常见问题
### 启动后看不到后端日志
`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志:
```bash
docker compose logs -f api
```
### API 容器反复重启,提示 Permission denied: '/data/uploads'
这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行:
```bash
docker compose down
mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage
sudo chown -R $(id -u):$(id -g) runtime
docker compose up -d
```
### 网页能打开,但客户端提示令牌校验失败
检查客户端 `config/app_config.json`
```json
"client_token": "..."
```
必须和服务端 `.env`
```text
CLIENT_API_TOKEN=...
```
完全一致。
### 客户端能连 API,但下载升级文件失败
检查 `.env`
```text
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
```
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
## 九、安全提醒
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
EOF_README
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
from pathlib import Path
import sys
path = Path(sys.argv[1])
version = sys.argv[2]
text = path.read_text(encoding='utf-8').replace('__VERSION__', version)
path.write_text(text, encoding='utf-8')
PYREADME
cat > "$PACKAGE_DIR/load-images.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
docker load -i ./images/simcae-server-all-images_$VERSION.tar
if [[ ! -f .env ]]; then
cp .env.example .env
sed -i "s/^APP_UID=.*/APP_UID=\$(id -u)/" .env
sed -i "s/^APP_GID=.*/APP_GID=\$(id -g)/" .env
echo "Created .env from .env.example. Edit .env before starting services."
else
echo ".env already exists. Keeping existing file."
fi
APP_UID_VALUE="\$(grep -E '^APP_UID=' .env | tail -n 1 | cut -d= -f2-)"
APP_GID_VALUE="\$(grep -E '^APP_GID=' .env | tail -n 1 | cut -d= -f2-)"
APP_UID_VALUE="\${APP_UID_VALUE:-\$(id -u)}"
APP_GID_VALUE="\${APP_GID_VALUE:-\$(id -g)}"
mkdir -p ./runtime/uploads ./runtime/upload_spool ./runtime/crash_storage ./minio_data
if [[ -w ./runtime ]]; then
chown -R "\$APP_UID_VALUE:\$APP_GID_VALUE" ./runtime 2>/dev/null || true
else
echo "Warning: ./runtime is not writable by the current user."
echo "Run: sudo chown -R \$APP_UID_VALUE:\$APP_GID_VALUE ./runtime"
fi
echo "Next: edit .env, then run: docker compose up -d"
EOF
chmod +x "$PACKAGE_DIR/load-images.sh"
python3 - "$PACKAGE_DIR/.env.example" "$IMAGE_NAME" <<'PYENV'
from pathlib import Path
import sys
path = Path(sys.argv[1])
image = sys.argv[2]
text = path.read_text(encoding='utf-8')
text = text.replace('SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0', f'SIMCAE_UPDATE_SERVER_IMAGE={image}')
path.write_text(text, encoding='utf-8')
PYENV
docker save -o "$TAR_FILE" "$IMAGE_NAME" "$MINIO_IMAGE" "$MC_IMAGE"
rm -f "$ARCHIVE_FILE"
tar -czf "$ARCHIVE_FILE" -C "$(dirname "$PACKAGE_DIR")" "$(basename "$PACKAGE_DIR")"
echo "Offline package directory: $PACKAGE_DIR"
echo "Offline package archive: $ARCHIVE_FILE"
echo "Images tar: $TAR_FILE"
-6
View File
@@ -1,6 +0,0 @@
cryptography==49.0.0
fastapi==0.138.0
minio==7.2.20
python-dotenv==1.2.2
python-multipart==0.0.32
uvicorn==0.49.0
-187
View File
@@ -1,187 +0,0 @@
-- 1.软件应用表:管理所有接入自动更新系统的软件
CREATE TABLE IF NOT EXISTS apps (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 自增主键ID
app_id TEXT NOT NULL UNIQUE, -- 软件唯一标识(客户端用来区分不同软件)
app_name TEXT NOT NULL -- 软件展示名称
);
-- 2.软件版本表:存储每个软件各个渠道下的所有版本号
CREATE TABLE IF NOT EXISTS versions (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 版本自增主键version_id,关联文件表
app_id TEXT NOT NULL, -- 关联apps表的软件唯一标识
channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版
version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2
latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版
client_protocol INTEGER NOT NULL DEFAULT 1, -- 该版本客户端支持的更新/策略协议版本
create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间
UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本
);
-- 3.版本关联文件表:记录每个版本需要更新的全部文件信息(exe、dll、资源文件)
CREATE TABLE IF NOT EXISTS version_files (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 文件记录自增ID
version_id INTEGER NOT NULL, -- 关联versions表的版本主键id
path TEXT NOT NULL, -- 文件在MinIO存储桶内的完整路径
sha256 TEXT NOT NULL, -- 文件哈希值,客户端下载后做完整性/防篡改校验
size INTEGER -- 文件字节大小,用于计算下载进度、校验磁盘空间
);
-- 4.升级日志记录表:存储所有客户端上报的升级结果,用于后台统计排查问题
CREATE TABLE IF NOT EXISTS upgrade_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 日志自增主键
device_id TEXT, -- 用户设备唯一标识,区分不同电脑客户端
from_ver TEXT, -- 升级前本地旧版本号
to_ver TEXT, -- 升级目标新版本号
result TEXT, -- 升级结果:success成功 / fail失败
create_time TEXT -- 升级上报时间
);
--5.日志数据表
CREATE TABLE IF NOT EXISTS update_report (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
app_id TEXT NOT NULL,
from_version TEXT NOT NULL,
to_version TEXT NOT NULL,
result TEXT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 6. 每个应用/渠道的运行与升级策略;每次保存必须递增 policy_seq
CREATE TABLE IF NOT EXISTS version_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'stable',
policy_seq INTEGER NOT NULL DEFAULT 1,
force_update INTEGER NOT NULL DEFAULT 0,
allow_rollback INTEGER NOT NULL DEFAULT 0,
offline_allowed INTEGER NOT NULL DEFAULT 1,
valid_until TEXT NOT NULL,
min_supported_version TEXT NOT NULL DEFAULT '',
disabled_versions TEXT NOT NULL DEFAULT '[]',
message TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(app_id, channel)
);
-- 7. RSA 签发的客户端设备身份
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL UNIQUE, app_id TEXT NOT NULL,
installation_id TEXT NOT NULL, machine_hash TEXT NOT NULL DEFAULT '', credential_seq INTEGER NOT NULL DEFAULT 1,
disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '',
first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), last_ip TEXT NOT NULL DEFAULT '',
UNIQUE(app_id, installation_id)
);
-- 8. 软件授权及其设备占用关系
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE,
customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable',
max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS license_devices (
license_id TEXT NOT NULL, device_id TEXT NOT NULL UNIQUE, bound_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY(license_id,device_id)
);
-- 9. 每个应用独立维护的动态发布渠道
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, channel_code TEXT NOT NULL,
display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100,
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(app_id, channel_code)
);
-- 10. 文件下载授权与客户端完成结果
CREATE TABLE IF NOT EXISTS download_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, license_id TEXT NOT NULL,
app_id TEXT NOT NULL, version TEXT NOT NULL, channel_code TEXT NOT NULL, file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0, result TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_download_logs_created ON download_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,created_at);
-- 11. 管理后台写操作审计
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL,
method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL,
status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at);
-- 12. SimCAE 崩溃报告原始数据索引
CREATE TABLE IF NOT EXISTS crash_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL UNIQUE,
client_report_id TEXT NOT NULL UNIQUE,
content_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'stored',
symbolication_status TEXT NOT NULL DEFAULT 'not_started',
product TEXT NOT NULL,
app_version TEXT NOT NULL,
git_commit TEXT NOT NULL,
build_type TEXT NOT NULL,
channel TEXT NOT NULL,
exception_code TEXT NOT NULL DEFAULT '',
crash_time_utc TEXT NOT NULL,
received_at_utc TEXT NOT NULL,
remote_address TEXT NOT NULL DEFAULT '',
content_length INTEGER NOT NULL DEFAULT 0,
metadata_sha256 TEXT NOT NULL,
metadata_size INTEGER NOT NULL DEFAULT 0,
minidump_sha256 TEXT NOT NULL,
minidump_size INTEGER NOT NULL DEFAULT 0,
attachments_sha256 TEXT NOT NULL DEFAULT '',
attachments_size INTEGER NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
metadata_json TEXT NOT NULL,
server_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_crash_reports_build ON crash_reports(product,app_version,git_commit);
CREATE INDEX IF NOT EXISTS idx_crash_reports_exception ON crash_reports(exception_code);
CREATE INDEX IF NOT EXISTS idx_crash_reports_received ON crash_reports(received_at_utc);
-- 13. SimCAE 符号包上传索引
CREATE TABLE IF NOT EXISTS crash_symbol_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol_upload_id TEXT NOT NULL UNIQUE,
identity_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'stored',
product TEXT NOT NULL,
app_version TEXT NOT NULL,
git_commit TEXT NOT NULL,
build_type TEXT NOT NULL,
platform TEXT NOT NULL,
toolchain TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
received_at_utc TEXT NOT NULL,
metadata_sha256 TEXT NOT NULL,
metadata_size INTEGER NOT NULL DEFAULT 0,
symbols_sha256 TEXT NOT NULL,
symbols_size INTEGER NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_crash_symbols_build ON crash_symbol_uploads(product,app_version,git_commit,build_type,platform);
CREATE INDEX IF NOT EXISTS idx_crash_symbols_received ON crash_symbol_uploads(received_at_utc);
-- 14. 崩溃原始文件访问审计
CREATE TABLE IF NOT EXISTS crash_file_access_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL,
file_name TEXT NOT NULL,
actor_hash TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_crash_file_access_report ON crash_file_access_logs(report_id,created_at);
-196
View File
@@ -1,196 +0,0 @@
# 服务端 Docker 镜像交付说明
这份说明用于把 SimCAE 自动升级/崩溃上报服务端交付给开发组或部署人员。
## 1. 要不要给开发组发 Docker 镜像
分两种情况:
- 如果开发组只负责接入客户端 SDK,通常不需要给他们镜像,只需要给一个已经部署好的测试服务地址、token、License 和 SDK 文档。
- 如果开发组需要本地完整联调,建议给他们一个服务端 Docker 部署包。部署包不要只有一个镜像,还要包含 docker-compose.image.yml、.env 示例、keys 目录说明。
## 2. 服务端包含哪些容器
当前服务端不是单容器,而是三个服务:
- api:FastAPI 服务,提供升级接口、后台页面、Manifest 签名、崩溃上报接口。
- minio:对象存储,用于保存版本文件。
- minio-init:启动时自动创建 MinIO bucket。
所以交付时推荐给 Docker Compose 部署包。
## 3. 构建服务端镜像
在项目根目录执行,注意 Dockerfile 的构建上下文必须是项目根目录,因为镜像会复制 server 代码和 client/admin.html。
```powershell
cd C:\Users\admin\Desktop
docker build `
-t simcae-update-server:0.1.0 `
-f server\Dockerfile `
.
```
验证镜像:
```powershell
docker images simcae-update-server
```
## 4. 导出镜像给别人
如果接收方机器可以访问 Docker Hub,只导出 api 镜像即可:
```powershell
docker save `
-o simcae-update-server_0.1.0.tar `
simcae-update-server:0.1.0
```
如果接收方机器不能联网,建议把 api、minio、minio-init 使用的三个镜像一起导出:
```powershell
docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z
docker pull minio/mc:RELEASE.2025-04-16T18-13-26Z
docker save `
-o simcae-server-all-images_0.1.0.tar `
simcae-update-server:0.1.0 `
minio/minio:RELEASE.2025-04-22T22-12-26Z `
minio/mc:RELEASE.2025-04-16T18-13-26Z
```
建议交付目录:
```text
SimCAEServerDockerPackage/
simcae-update-server_0.1.0.tar
docker-compose.image.yml
.env.example
keys/
manifest_private_key.pem
README.md
```
注意:manifest_private_key.pem 是服务端签 Manifest 的私钥。客户端 SDK 中的 manifest_public_key.pem 必须和它匹配。
## 4.1 推荐:在 Ubuntu 上使用离线打包脚本
如果接收方服务器不能联网,推荐直接使用 Ubuntu/bash 脚本生成完整离线包。脚本会完成:构建 api 镜像、拉取 MinIO 镜像、导出三张镜像、复制 docker-compose.yml、复制 .env.example、复制 keys、生成快速启动说明,并压缩成 tar.gz。
在当前 Ubuntu 机器上执行:
```bash
cd /path/to/project/server
bash ./package-offline-server.sh \
--version 0.1.0 \
--output-dir ./dist/SimCAEServerDockerPackage
```
生成结果:
```text
server/dist/SimCAEServerDockerPackage/
images/
simcae-server-all-images_0.1.0.tar
keys/
manifest_private_key.pem
manifest_public_key.pem
docker-compose.yml
.env.example
README.md
QUICK_START.md
load-images.sh
server/dist/SimCAEServerDockerPackage.tar.gz
```
`SimCAEServerDockerPackage.tar.gz` 拷贝到离线服务器即可。
如果是在 Windows 机器上打包,也可以使用 `package-offline-server.ps1`,但 Ubuntu 服务端主流程建议使用 `package-offline-server.sh`
## 5. 离线 Ubuntu 服务器如何运行
接收方服务器需要提前安装好 Docker Engine 和 Docker Compose plugin。离线包拷过去后执行:
```bash
tar -xzf SimCAEServerDockerPackage.tar.gz
cd SimCAEServerDockerPackage
bash ./load-images.sh
nano .env
```
`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址:
```text
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
```
正式部署时,建议同时修改这些默认值,避免所有环境共用同一套公开示例密码:
```text
CLIENT_API_TOKEN=客户端 API token,对应客户端 app_config.json 的 client_token
ADMIN_TOKEN=后台管理员 token,网页登录时填这个值
CRASH_REPORT_TOKEN=崩溃上传 token
CRASH_SYMBOL_TOKEN=符号包上传 token
MINIO_ACCESS_KEY=MinIO 用户名
MINIO_SECRET_KEY=MinIO 密码
```
如果你在网页里点击“更改令牌”,新令牌只会在当前服务进程中立即生效。为了让服务重启后仍然使用新令牌,请同步修改 `.env` 里的 `ADMIN_TOKEN`,然后执行 `docker compose restart api`
启动:
```bash
docker compose up -d
```
查看状态:
```bash
docker compose ps
```
查看日志:
```bash
docker compose logs -f api
```
访问:
```text
后台/API: http://服务器IP:8000/
MinIO 控制台: http://服务器IP:9001/
```
如果服务器开启了防火墙,至少放行:
```text
8000 后台/API
9000 客户端下载升级文件
9001 MinIO 控制台,可按需限制访问
```
## 6. 客户端 SDK 需要哪些服务端信息
发给客户端开发组的信息通常是:
```text
api_base_url=http://服务器IP:8000
client_token=CLIENT_API_TOKEN 的值
license_key=后台创建的 License
manifest_public_key.pem=与服务端 manifest_private_key.pem 匹配的公钥
crash_report_token=CRASH_REPORT_TOKEN 的值,如果接入崩溃上报
```
## 7. 生产部署提醒
- 不要把生产私钥和 token 发到无关人员手里。
- 正式环境需要备份 runtime、minio_data、keys。
- 对外开放端口至少包括 8000 和 9000;9001 是 MinIO 控制台,生产环境可限制访问。
- 修改服务端签名私钥后,必须重新生成客户端 manifest_public_key.pem,并重新打 SDK/客户端包。
Submodule
+1
Submodule update-client added at d9e32c6cea
Submodule
+1
Submodule update-server added at 801622904c
+73
View File
@@ -0,0 +1,73 @@
# 项目交付状态一页纸
这份只看当前状态,不记录开发历史。更详细的背景和概念看 `项目总览.md`
## 项目是什么
这是 SimCAE 自动升级、授权、版本发布和崩溃报告服务的一套客户端 SDK + 服务端后台。
```text
update-client/ Launcher / Updater / Bootstrap、SDK 打包、客户端接入文档
update-server/ FastAPI 后端、Vue 管理后台、Docker 离线部署包
```
## 当前可以交付什么
- Windows/Linux 客户端升级运行时:`Launcher``Updater``Bootstrap`
- 客户端 SDK 包:包含运行程序、配置模板、接入文档、最终客户端打包脚本。
- 服务端 Docker 离线部署包:包含 API 镜像、管理后台、MinIO、compose 文件、配置模板和 Manifest 签名密钥。
- 管理后台:基于 pure-admin-thin / Vue3 / Element Plus / TypeScript / Vite。
- 后端:基于 FastAPI,已经按 routes / services / repositories / schemas / core 分层,并支持 JWT / RBAC。
- 后端自动化测试:pytest,覆盖登录/JWT、License/设备、更新检查、发布和崩溃报告核心流程。
## 已完成的核心功能
- 应用、渠道、版本发布、策略管理。
- License 授权、设备登记、设备数限制。
- Manifest 生成、SHA-256 文件校验、私钥签名、公钥验签。
- 客户端检查更新、下载、安装、健康检查、失败回滚。
- 升级日志、下载日志、管理员审计日志。
- 发布清单 `发布清单.txt` / `release_manifest.txt`,支持选择发布哪些文件、排除哪些文件。
- 发布压缩包支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar。
- 发布任务后台化:上传完成后,服务端后台继续处理,页面轮询任务状态。
- 崩溃报告上传、查询、文件下载和符号包上传接口。
- Git 标签清单:管理后台策略可控制 Launcher 是否生成 `tags.txt`;服务端用 `GITEA_TOKEN` 拉取仓库 tags,客户端永远拿不到 token,只拿整理后的文本。
## 当前要特别注意
- `keys/manifest_private_key.pem` 必须在真实服务端部署包里,但不能进 Git、不能给客户端、不能公开传播。
- 客户端 SDK 里的 `manifest_public_key.pem` 必须和服务端私钥配套。
- `config/server_config.json` 会编进客户端程序;改服务端地址后必须重新编译 Launcher / Updater / Bootstrap。
- `GITEA_TOKEN` 只允许放在服务端 `.env`,不要写进客户端配置、不要给 Launcher、不要提交 Git。
- `app_config.json` 首次启动会导入当前用户配置区,非空配置导入后会清空为 `{}`
- Manifest 缓存和设备身份等运行态文件默认在当前用户数据目录,不再默认写安装目录。
- 公网部署前仍建议改默认 token/password、配置 HTTPS、限制 MinIO 控制台暴露范围、收紧 CORS。
## 打包入口
客户端 SDK
```powershell
cd update-client
.\scripts\package-sdk.ps1 -SourceDir .\out\bin\Release -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
```
服务端 Docker 离线包:
```bash
cd update-server
bash ./scripts/package-offline-server.sh --version 0.1.0 --output-dir ./dist/SimCAEServerDockerPackage
```
服务端自动化测试:
```bash
cd update-server
./venv/bin/python3 -m pytest -q
```
## 还不算完成的事情
- 真实 Linux 版 SimCAE 主程序还没有交付联调。
- 公网生产级安全加固还未全部自动化,例如登录限流、HTTPS 自动化、默认密钥强制替换。
- 数据库迁移仍是 `tables.sql` + 手写兼容逻辑,长期维护可考虑 Alembic。
+193 -116
View File
@@ -2,15 +2,25 @@
本文档用于总览当前项目的目标、已实现能力、主要目录、交付物和后续注意事项。适合在提交代码、交接项目或给他人快速了解项目时阅读。 本文档用于总览当前项目的目标、已实现能力、主要目录、交付物和后续注意事项。适合在提交代码、交接项目或给他人快速了解项目时阅读。
如果只想快速判断“现在能交付什么、还要注意什么”,先看根目录的 `项目交付状态一页纸.md`
## 一、项目整体是什么 ## 一、项目整体是什么
本项目是一套面向 Windows 客户端软件的自动升级与版本控制系统,并补充实现了 SimCAE 崩溃报告后端接口。 本项目是一套面向 Windows/Linux 客户端软件的自动升级与版本控制系统,并补充实现了 SimCAE 崩溃报告后端接口。
整体分为两部分: 整体分为两部分:
```text ```text
client/ Windows 客户端升级运行时、SDK 打包脚本、接入说明文档 update-client/ Windows/Linux 客户端升级运行时、SDK 打包脚本、接入说明文档
server/ FastAPI 服务端、后台页面、Docker 部署和离线交付脚本 update-server/ FastAPI 服务端、后台页面、Docker 部署和离线交付脚本
```
文档放置约定:
```text
项目总览.md 项目整体说明,保留在总仓库根目录
update-client/Docs/ 客户端说明文档
update-server/Docs/ 服务端说明文档
``` ```
客户端负责: 客户端负责:
@@ -37,18 +47,19 @@ server/ FastAPI 服务端、后台页面、Docker 部署和离线交付脚本
客户端核心程序: 客户端核心程序:
```text ```text
Launcher.exe 用户入口,检查更新并启动业务主程序 Launcher.exe / Launcher 用户入口,检查更新并启动业务主程序
Updater.exe 下载、校验、安装、提交或回滚 Updater.exe / Updater 下载、校验、安装、提交或回滚
Bootstrap.exe 辅助替换运行中的 EXE/DLL Bootstrap.exe / Bootstrap 辅助替换运行中的 EXE/DLL 或 Linux 可执行文件
MainApp.exe 当前仓库内的示例业务程序 MainApp.exe / MainApp 当前仓库内的示例业务程序
``` ```
已经实现的能力: 已经实现的能力:
- 读取 `config/app_config.json` 配置 - 首次启动时把 `config/app_config.json` 导入当前 Windows 用户注册表,后续普通运行配置优先从注册表读取;如果解析后的 JSON 内容变化,下一次启动会自动重新导入,并清理 `config/client_identity.dat``config/version_policy.dat`
- 服务端 API 地址 `api_base_url` 不再写入 `app_config.json` 或注册表,而是写入 `config/server_config.json` 并通过 `config/server_config.qrc` 编译进 Launcher / Updater。
- 首次设备登记和本地设备身份校验。 - 首次设备登记和本地设备身份校验。
- License 校验。 - License 校验。
- `license_key` 为空时,`Launcher.exe` 会弹窗让用户粘贴后台创建的 License,并写入 `config/app_config.json`。当前实现会先检查配置项;即使本地已有 `config/client_identity.dat`,只要清空 `license_key` 仍会提示用户补填 License。 - `license_key` 为空时,`Launcher.exe` 会弹窗让用户粘贴后台创建的 License,并写入注册表。当前实现会先检查配置项;即使本地已有 `config/client_identity.dat`,只要清空 `license_key` 仍会提示用户补填 License。
- License 过期、错误、禁用、设备数达到上限或设备身份凭证与授权不匹配时,`Launcher.exe` 会引导用户重新输入 License,而不是要求用户手动查找配置文件。 - License 过期、错误、禁用、设备数达到上限或设备身份凭证与授权不匹配时,`Launcher.exe` 会引导用户重新输入 License,而不是要求用户手动查找配置文件。
- 版本策略校验。 - 版本策略校验。
- 防止策略序号回退。 - 防止策略序号回退。
@@ -60,33 +71,53 @@ MainApp.exe 当前仓库内的示例业务程序
- 升级后健康检查 `--health-file`,业务程序启动成功后写入 `ok` - 升级后健康检查 `--health-file`,业务程序启动成功后写入 `ok`
- 升级失败回滚。 - 升级失败回滚。
- 升级日志和下载日志上报服务端。 - 升级日志和下载日志上报服务端。
- `server/admin.html` 管理后台布局已优化,升级日志和下载日志默认折叠 - Git 标签清单生成:如果服务端策略开启,`Launcher` 会请求服务端生成 Git tags 文本,并写入 `Launcher` 同目录下的 `tags.txt`;客户端不会接触 Git token
- Launcher / Updater / Bootstrap 已支持 Windows 和 Linux Qt 跨平台编译。
## 三、客户端 SDK 交付状态 ## 三、客户端 SDK 交付状态
已经提供 SDK 打包脚本: 已经提供 Windows 和 Linux SDK/客户端打包脚本:
```text ```text
client/package-sdk.ps1 update-client/scripts/package-sdk.ps1
client/package-client.ps1 update-client/scripts/package-client.ps1
update-client/scripts/package-sdk.sh
update-client/scripts/package-client.sh
``` ```
`package-sdk.ps1` 用于生成给业务开发者接入的 SDK 包。当前 SDK 包结构设计为 客户端文档现在统一放在
```text
update-client/Docs/00-先读我-客户端文档入口.txt
update-client/Docs/01-客户端接入打包部署指南.md
update-client/Docs/02-编译环境和第三方依赖说明.md
```
`package-sdk.ps1` / `package-sdk.sh` 用于生成给业务开发者接入的 SDK 包。当前 SDK 包结构设计为:
```text ```text
UpdateClientSDK/ UpdateClientSDK/
SimCAE自动升级SDK接入说明_v0.1.docx
sdk_manifest.json sdk_manifest.json
Docs/
00-先读我-客户端文档入口.txt
01-客户端接入打包部署指南.md
02-编译环境和第三方依赖说明.md
bin/ bin/
config/ config/
app_config.json app_config.json
manifest_public_key.pem manifest_public_key.pem
Common/
ConfigHelper.h/.cpp
TicketHelper.h/.cpp
scripts/ scripts/
SimCAE自动升级SDK接入说明_v0.1.docx 可选,本地存在时才会随包复制
``` ```
为了降低接入方理解成本,SDK 根目录只保留一个 Word 说明文档作为入口 为了降低接入方理解成本,SDK 包内默认带 `Docs/00-先读我-客户端文档入口.txt`。Word 接入说明是可选增强,`.docx` 不进 Gitfresh clone 没有 Word 时也能打包 SDK
Word 文档已经说明: Linux 当前已完成 Qt 跨平台编译、Linux SDK 打包脚本、Linux 最终客户端打包脚本、Linux 配置模板和部署说明。真实 Linux 版 SimCAE 主程序还未由业务开发方交付,因此真实业务联调暂未完成。
客户端接入文档已经说明:
- 拿到 `UpdateClientSDK.zip` 后怎么操作。 - 拿到 `UpdateClientSDK.zip` 后怎么操作。
- 如何复制 SDK 运行时到业务软件 Release 目录。 - 如何复制 SDK 运行时到业务软件 Release 目录。
@@ -99,14 +130,37 @@ Word 文档已经说明:
## 四、服务端当前已实现 ## 四、服务端当前已实现
服务端使用 FastAPI,主要文件 服务端采用的成熟框架和模板生态
- 后端 HTTP 框架:`FastAPI`,运行服务使用 `Uvicorn`,请求模型和参数校验使用 FastAPI 自带的 `Pydantic` 体系。
- 后端工程结构:参考 FastAPI 官方 full-stack 模板、FastAPI Users 等成熟 FastAPI 后台项目的分层方式,拆成 `routes / services / repositories / schemas / core`
- 管理后台前端:使用 GitHub 上的 `pure-admin-thin` / `vue-pure-admin` 生态,技术栈是 `Vue3 + Element Plus + TypeScript + Vite`
- 存储组件:元数据使用 SQLite,版本文件和崩溃文件使用 MinIO 对象存储。
- Git 标签清单:服务端支持通过 `GITEA_BASE_URL` / `GITEA_TOKEN` 访问 Gitea API,管理后台策略页可以控制 Launcher 是否生成 `tags.txt`,并在后台预览当前仓库 tags。`GITEA_TOKEN` 只保存在服务端 `.env`,不会返回给客户端。
- 当前说明:后端已经完成工程化分层,并已加入用户名/密码登录、Argon2 密码哈希、JWT access/refresh token、RBAC 权限检查和管理员用户管理页面。当前实现是轻量用户体系,后续仍可继续接入 FastAPI Users 等更完整的成熟认证组件。
- 自动化测试:使用 pytest,测试会创建隔离的临时数据库、临时 Manifest 私钥和临时存储目录,覆盖管理员登录/JWT、License/设备登记、更新检查、发布事务和崩溃报告核心流程。
当前后端已经从早期单文件形态拆成分层结构,主要入口和目录:
```text ```text
server/main.py update-server/main.py 应用启动、数据库初始化、MinIO 初始化、全局客户端鉴权
server/db.py update-server/app/api/routes/ 管理端、客户端更新、崩溃报告等 HTTP 路由
server/tables.sql update-server/app/services/ 发布、签名、License、设备凭证、崩溃报告等业务逻辑
server/minio_tool.py update-server/app/repositories/ SQLite 数据库读写层
server/admin.html 服务端管理后台页面,Docker 构建时复制进镜像 update-server/app/schemas/ Pydantic 请求模型
update-server/db.py
update-server/tables.sql
update-server/minio_tool.py
update-server/admin-ui/ 新版管理后台源码
```
服务端文档现在统一放在:
```text
update-server/Docs/00-先读我-服务端文档入口.txt
update-server/Docs/01-服务端Docker打包部署指南.md
update-server/Docs/02-崩溃报告接口联调指南.md
update-server/Docs/03-后端工程化结构说明.md
``` ```
已经实现的能力: 已经实现的能力:
@@ -116,6 +170,7 @@ server/admin.html 服务端管理后台页面,Docker 构建时复制进镜像
- License 管理。 - License 管理。
- 设备首次登记。 - 设备首次登记。
- 版本发布。 - 版本发布。
- 版本发布已支持后台任务模式:浏览器上传完成后,服务端继续在后台校验、入库和上传 MinIO,管理页面轮询任务状态。
- 版本发布支持两种方式:选择软件发布根目录,或上传压缩好的发布包。管理页面已经拆成“软件根目录/压缩发布包”两个发布方式,避免用户误以为只能选择文件夹。 - 版本发布支持两种方式:选择软件发布根目录,或上传压缩好的发布包。管理页面已经拆成“软件根目录/压缩发布包”两个发布方式,避免用户误以为只能选择文件夹。
- 压缩发布包支持 `zip``tar.gz``tgz``tar.bz2``tbz2``rar`;服务端会先解压,再按 `RELEASE_MAIN_EXECUTABLE` 校验主程序路径。 - 压缩发布包支持 `zip``tar.gz``tgz``tar.bz2``tbz2``rar`;服务端会先解压,再按 `RELEASE_MAIN_EXECUTABLE` 校验主程序路径。
- Manifest 生成和私钥签名。 - Manifest 生成和私钥签名。
@@ -125,6 +180,7 @@ server/admin.html 服务端管理后台页面,Docker 构建时复制进镜像
- 升级结果上报。 - 升级结果上报。
- 文件下载日志记录。 - 文件下载日志记录。
- 后台管理页面。 - 后台管理页面。
- 后端已经按 `routes / services / repositories / schemas` 拆分,`main.py` 不再堆业务接口。
- Docker 运行。 - Docker 运行。
- Docker 离线部署包生成。 - Docker 离线部署包生成。
@@ -161,20 +217,20 @@ POST /api/v1/symbols
服务端 Docker 文件: 服务端 Docker 文件:
```text ```text
server/Dockerfile update-server/Dockerfile
server/docker-compose.yml update-server/docker-compose.yml
server/docker-compose.image.yml update-server/docker-compose.image.yml
server/package-offline-server.sh update-server/scripts/package-offline-server.sh
server/.env.example update-server/.env.example
server/.env.docker.example update-server/.env.docker.example
``` ```
Ubuntu 主流程使用: Ubuntu 主流程使用:
```bash ```bash
cd server cd update-server
bash ./package-offline-server.sh \ bash ./scripts/package-offline-server.sh \
--version 0.1.0 \ --version 0.1.0 \
--output-dir ./dist/SimCAEServerDockerPackage --output-dir ./dist/SimCAEServerDockerPackage
``` ```
@@ -182,7 +238,7 @@ bash ./package-offline-server.sh \
生成: 生成:
```text ```text
server/dist/SimCAEServerDockerPackage.tar.gz update-server/dist/SimCAEServerDockerPackage.tar.gz
``` ```
该包中包含: 该包中包含:
@@ -202,8 +258,8 @@ server/dist/SimCAEServerDockerPackage.tar.gz
注意:Docker 镜像包只包含软件本体和部署配置,不包含已经运行出来的数据。当前服务器上的数据库、上传过的版本文件、崩溃报告等数据在: 注意:Docker 镜像包只包含软件本体和部署配置,不包含已经运行出来的数据。当前服务器上的数据库、上传过的版本文件、崩溃报告等数据在:
```text ```text
server/runtime/ update-server/runtime/
server/minio_data/ update-server/minio_data/
``` ```
如果要完整迁移现有服务数据,需要额外备份和恢复这两个目录。 如果要完整迁移现有服务数据,需要额外备份和恢复这两个目录。
@@ -257,15 +313,15 @@ manifest_public_key.pem
不建议提交: 不建议提交:
- `client/App/` - `update-client/App/`
- `client/out/` - `update-client/out/`
- `client/dist/` - `update-client/dist/`
- `server/dist/` - `update-server/dist/`
- `server/runtime/` - `update-server/runtime/`
- `server/minio_data/` - `update-server/minio_data/`
- `server/*.db` - `update-server/*.db`
- `server/.env` - `update-server/.env`
- `server/keys/manifest_private_key.pem` - `update-server/keys/manifest_private_key.pem`
- 任何真实 token、License、私钥和运行日志。 - 任何真实 token、License、私钥和运行日志。
## 九、SimCAE 安装根目录模式 ## 九、SimCAE 安装根目录模式
@@ -291,9 +347,9 @@ SimCAE/
## 十、运行目录权限说明 ## 十、运行目录权限说明
当前客户端 SDK 默认把运行时状态写`Launcher.exe` 所在目录下,包括 `config/app_config.json``config/client_identity.dat``config/local_state.json``config/version_policy.dat``update/``update_temp/`。当 SDK 放在 `SimCAE/bin` 时,更新事务、备份、暂存文件、下载断点和 Manifest 缓存都`SimCAE/bin/update/`,不会再在 `SimCAE/` 根目录生成顶层 `update/`因此联调目录必须允许当前 Windows 用户写入。 当前客户端 SDK 把部署配置源放`Launcher.exe` 所在目录下 `config/app_config.json`,启动时同步到当前用户的 QSettings 配置区;Windows 下对应注册表路径 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`Linux 下由 Qt QSettings 写入当前用户配置目录。服务端 API 地址 `api_base_url` 是例外:它位于源码 `config/server_config.json`,通过 `config/server_config.qrc` 编译进程序,不再写入 `app_config.json` 或注册表。如果检测到 `app_config.json` 变化,SDK 会删除当前用户数据目录里的 `client_identity.dat``version_policy.dat``local_state.json`,避免旧授权身份、旧策略或旧防回滚状态继续生效。设备身份、状态、策略、更新事务、备份、暂存文件、下载断点和 Manifest 缓存默认位于当前用户数据目录,例如 Windows:`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\`。当 SDK 放`SimCAE/bin`,不会再在 `SimCAE/` 根目录生成顶层 `update/`
如果把软件放在 `C:\Program Files\SimCAE\bin` 并用普通用户启动,Windows 会拒绝写入,首次启动可能提示 `cannot save installation id`。当前建议先在 `D:\SimCAE_Release` 或桌面目录联调;正式要安装到 `Program Files` 时,需要补管理员提权、Windows 服务,或将运行时状态迁移到 `ProgramData` / `AppData` 如果把软件放在 `C:\Program Files\SimCAE\bin` 并用普通用户启动,普通配置值已经不需要写回 `app_config.json`;但设备身份、状态、策略、更新缓存等文件仍可能需要写安装目录。当前代码对小型状态文件写入已有管理员权限确认;正式要完整安装到 `Program Files` 并自动升级大文件时,后续仍建议补 Windows 服务,或将更多运行时状态迁移到 `ProgramData` / `AppData`
## 十一、后续待办 ## 十一、后续待办
@@ -309,12 +365,12 @@ SimCAE/
## 附录:原项目进度记录 ## 附录:原项目进度记录
以下内容迁移自原 `client/项目进度.txt`,用于保留更详细的阶段性进度、功能清单和概念说明。 以下内容迁移自原 `update-client/项目进度.txt`,用于保留更详细的阶段性进度、功能清单和概念说明。
```text ```text
软件自动升级与版本控制系统——项目进度 软件自动升级与版本控制系统——项目进度
更新时间:2026-07-07 更新时间:2026-07-07
依据:《软件自动升级与版本控制系统开发设计文档 v0.1》、《SimCAE_Crash_Report后端接口规范》及当前 server/client 源码 依据:《软件自动升级与版本控制系统开发设计文档 v0.1》、《SimCAE_Crash_Report后端接口规范》及当前 update-server/update-client 源码
============================================================ ============================================================
一、项目进度概述 一、项目进度概述
@@ -360,17 +416,22 @@ SimCAE 崩溃报告后端已经完成第一阶段核心接口:接收 SimCAECra
说明:MainApp 当前读取 Demo DLL 内容并显示 Hash 标识,但还没有通过 QLibrary 真正加载并调用插件接口,因此“插件实际加载”只算部分完成。 说明:MainApp 当前读取 Demo DLL 内容并显示 Hash 标识,但还没有通过 QLibrary 真正加载并调用插件接口,因此“插件实际加载”只算部分完成。
2.2 本地 JSON 配置 2.2 本地配置
已实现: 已实现:
1. 使用 config/app_config.json 保存客户端配置 1. 使用 `config/app_config.json` 作为部署配置源文件
2. 支持旧 client.ini 自动迁移 2. Launcher / Updater / MainApp 启动时按解析后的 JSON 内容 SHA-256 判断配置是否变化,变化后自动导入当前 Windows 用户注册表
3. 保存 API 地址、App ID、当前版本、渠道、设备 ID、客户端 Token、启动 Token、平台和架构 3. 注册表路径为 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`
4. 更新成功后写入 current_version 4. 后续运行配置优先从注册表读取,运行时动态值写入注册表
5. 使用 QSaveFile 原子写入主要配置 5. 检测到 JSON 变化后自动清理 `client_identity.dat` 和 `version_policy.dat`
6. 运行时配置不进入发布包 6. 支持旧 client.ini 自动迁移
7. 已配置项目 .gitignore 7. 保存 App ID、当前版本、渠道、设备 ID、客户端 Token、启动 Token、平台和架构
8. 使用 `config/server_config.json` + `config/server_config.qrc` 把 API 地址编译进程序,避免写入 `app_config.json` 或注册表。
8. 更新成功后写入 current_version。
9. 文件型本地状态仍使用 QSaveFile 原子写入。
10. 运行时配置不进入发布包。
11. 已配置项目 .gitignore。
2.3 服务端基础能力 2.3 服务端基础能力
@@ -614,20 +675,23 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
已实现: 已实现:
1. 管理员令牌输入、隐藏、显示和保存 1. 用户名和密码登录管理后台
2. 修改管理员令牌 2. 登录成功后由服务端签发 JWT access token 和 refresh token,管理接口使用 `Authorization: Bearer ...` 调用
3. 创建和选择应用 3. 管理员密码修改
4. 选择软件根目录发布 4. RBAC 角色权限校验,目前内置超级管理员、发布管理员、授权管理员、审计人员和崩溃报告管理员
5. 切换到“压缩发布包”模式上传压缩包发布,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar 5. 管理员用户管理:创建用户、编辑显示名称和角色、启用/禁用、重置密码,并防止禁用最后一个超级管理员
6. stable、preview、dev 渠道选择 6. 创建和选择应用
7. 版本列表 7. 选择软件根目录发布
8. 设置最新版本 8. 切换到“压缩发布包”模式上传压缩包发布,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
9. 删除版本和云端文件 9. stable、preview、dev 渠道选择
10. 版本策略读取和保存 10. 版本列表
11. 升级日志 11. 设置最新版本
12. 调试输出 12. 删除版本和云端文件
13. 发布文件预览、大小和状态提示 13. 版本策略读取和保存
14. 页面美化和响应式布局 14. 升级日志
15. 调试输出。
16. 发布文件预览、大小和状态提示。
17. 页面美化和响应式布局。
2.16 一次性短期启动票据 2.16 一次性短期启动票据
@@ -665,14 +729,14 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
3. 服务端逐请求验证设备凭证、授权状态、设备禁用状态、app_id 和 channel。 3. 服务端逐请求验证设备凭证、授权状态、设备禁用状态、app_id 和 channel。
4. License 授权:licenses 和 license_devices 表,支持有效期、启用/禁用、最大设备数和设备占用。 4. License 授权:licenses 和 license_devices 表,支持有效期、启用/禁用、最大设备数和设备占用。
5. 授权密钥只保存 SHA-256,明文只在创建时返回一次。 5. 授权密钥只保存 SHA-256,明文只在创建时返回一次。
6. Launcher 首次启动时如果没有 License,会弹窗要求用户输入并自动写入配置;当前实现会先检查 `license_key` 配置项,即使本地已有 `client_identity.dat`,清空 `license_key` 仍会触发输入框。 6. Launcher 首次启动时如果没有 License,会弹窗要求用户输入并自动写入注册表;当前实现会先检查 `license_key` 配置项,即使本地已有 `client_identity.dat`,清空 `license_key` 仍会触发输入框。
7. License 错误、过期、禁用、设备数达到上限或设备身份与授权不匹配时,Launcher 会清理旧设备身份并引导用户重新输入 License。 7. License 错误、过期、禁用、设备数达到上限或设备身份与授权不匹配时,Launcher 会清理旧设备身份并引导用户重新输入 License。
8. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。 8. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。
9. 发布、策略、授权、设备登记和更新检查统一校验渠道。 9. 发布、策略、授权、设备登记和更新检查统一校验渠道。
10. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。 10. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。
11. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。 11. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。
12. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。 12. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。
13. 管理员审计日志:记录 /admin 写操作、管理员令牌指纹、路径、结果、状态码、IP 和 User-Agent。 13. 管理员审计日志:记录 /admin 写操作、管理员账号、角色、鉴权方式、路径、结果、状态码、IP 和 User-Agent。
2.19 SimCAE Crash Report 后端第一阶段 2.19 SimCAE Crash Report 后端第一阶段
@@ -761,16 +825,26 @@ RSA 签名已经实现,但仍缺少:
3.7 管理员登录 3.7 管理员登录
当前采用单管理员 Token,服务端用 .env 中的 ADMIN_TOKEN 校验,并且已经记录 /admin 写操作审计 当前管理后台已经从单管理员 Token 升级为用户名/密码登录、JWT access/refresh token 和 RBAC 权限检查
尚缺少 已实现
1. admin_users 表。 1. admin_users 表。
2. 正式登录接口 2. admin_refresh_tokens 表
3. Token 过期时间 3. 初始管理员自动创建
4. 多管理员和角色权限 4. Argon2 密码哈希
5. 登录失败审计 5. JWT access token 和 refresh token
6. 会话吊销和 Token 轮换流程 6. 角色权限映射,例如 super_admin、release_admin、license_admin、auditor、crash_admin
7. 管理员用户管理接口和页面:列表、创建、编辑角色、启用/禁用、重置密码。
8. 管理后台写操作审计记录管理员用户名、角色、认证方式和操作结果。
`ADMIN_TOKEN` 仍保留为旧脚本兼容令牌、JWT_SECRET 未配置时的默认签名密钥,以及 CRASH_ADMIN_TOKEN 为空时的崩溃报告管理兜底令牌。
仍可继续加强:
1. 登录失败审计和频率限制。
2. 更细粒度的会话管理和 Token 轮换策略。
3. 接入 FastAPI Users 或同类成熟用户认证模块,替换当前轻量用户表。
3.8 数据库结构 3.8 数据库结构
@@ -905,12 +979,12 @@ RSA 签名已经实现,但仍缺少:
1. admin_audit_logs 表。 1. admin_audit_logs 表。
2. 统一中间件记录所有 /admin 写操作。 2. 统一中间件记录所有 /admin 写操作。
3. 记录管理员令牌截断 Hash 指纹,不保存令牌明文。 3. 记录管理员账号、角色、鉴权方式和兼容令牌截断 Hash 指纹,不保存令牌明文。
4. 记录操作路径、HTTP 方法、查询目标、成功/失败、状态码、IP、User-Agent 和时间。 4. 记录操作路径、HTTP 方法、查询目标、成功/失败、状态码、IP、User-Agent 和时间。
5. 鉴权失败及业务失败同样进入审计。 5. 鉴权失败及业务失败同样进入审计。
6. 审计日志查询 API 和管理后台列表。 6. 审计日志查询 API 和管理后台列表。
审计中间件不保存请求正文,避免 License 密钥、新管理员令牌等敏感内容进入日志。 审计中间件不保存请求正文,避免 License 密钥、新密码、兼容令牌等敏感内容进入日志。
4.11 代码签名 4.11 代码签名
@@ -922,7 +996,7 @@ RSA 签名已经实现,但仍缺少:
4.13 多平台 4.13 多平台
目前只支持 Windows x64,尚未支持 Windows ARM64、Linux、macOS多平台 Manifest 分流。 Windows x64 已完成主要联调;Linux x64 已完成 Qt 跨平台编译、SDK 打包脚本、客户端打包脚本、配置模板和服务端发布配置支持。尚未完成 Windows ARM64、macOS多平台 Manifest 分流,以及真实 Linux 版 SimCAE 主程序实机联调
============================================================ ============================================================
五、需求文档 Demo 里程碑状态 五、需求文档 Demo 里程碑状态
@@ -1001,7 +1075,7 @@ SimCAE Crash Report 第一阶段——已实现。
8.1 服务端是什么 8.1 服务端是什么
服务端在 server/ 下,核心是 FastAPI 应用。它负责: 服务端在 update-server/ 下,核心是 FastAPI 应用。它负责:
1. 管理应用、版本、渠道和策略。 1. 管理应用、版本、渠道和策略。
2. 接收管理员发布的软件文件。 2. 接收管理员发布的软件文件。
@@ -1010,7 +1084,7 @@ SimCAE Crash Report 第一阶段——已实现。
5. 对 Manifest、策略、设备身份、离线包做 RSA 签名。 5. 对 Manifest、策略、设备身份、离线包做 RSA 签名。
6. 给客户端签发临时下载 URL。 6. 给客户端签发临时下载 URL。
7. 接收升级结果、下载结果、设备登记和 License 授权。 7. 接收升级结果、下载结果、设备登记和 License 授权。
8. 提供 admin.html 管理网页 8. 提供新版 admin-ui 管理后台;旧版 admin.html 已在开发阶段移除,不再保留兼容入口
9. 接收 SimCAE 崩溃报告和符号包。 9. 接收 SimCAE 崩溃报告和符号包。
服务端主要存储有三类: 服务端主要存储有三类:
@@ -1021,7 +1095,7 @@ SimCAE Crash Report 第一阶段——已实现。
8.2 客户端是什么 8.2 客户端是什么
客户端在 client/ 下,主要由几个程序配合: 客户端在 update-client/ 下,主要由几个程序配合:
1. Launcher.exe:入口程序。负责检查更新、验证策略、决定是否启动 MainApp 或 Updater。 1. Launcher.exe:入口程序。负责检查更新、验证策略、决定是否启动 MainApp 或 Updater。
2. Updater.exe:真正下载和安装文件的程序。负责 Manifest 验证、差异下载、断点续传、备份、校验、提交或回滚。 2. Updater.exe:真正下载和安装文件的程序。负责 Manifest 验证、差异下载、断点续传、备份、校验、提交或回滚。
@@ -1233,10 +1307,10 @@ symbols.zip 就是 CI 或发布流程上传的符号包。它按 product、appVe
1. CLIENT_API_TOKEN:客户端安装包自带的公共门槛,用于首次设备登记等基础访问。 1. CLIENT_API_TOKEN:客户端安装包自带的公共门槛,用于首次设备登记等基础访问。
2. license_key:客户授权密钥,证明这个客户/项目有权使用某个 app/channel,并限制设备数和有效期。 2. license_key:客户授权密钥,证明这个客户/项目有权使用某个 app/channel,并限制设备数和有效期。
3. device_id/client_identity.dat:服务端给某台安装实例签发的设备身份,之后每次更新请求都要带。 3. device_id/client_identity.dat:服务端给某台安装实例签发的设备身份,之后每次更新请求都要带。
4. X-Admin-Token:管理后台请求头里传的管理员令牌;当前服务端用 .env 里的 ADMIN_TOKEN 校验 4. 管理后台 JWT:网页登录成功后使用 `Authorization: Bearer <accessToken>` 调用后台接口
5. CRASH_REPORT_TOKENSimCAECrashReporter.exe 上传崩溃报告用的 Bearer Token。 5. CRASH_REPORT_TOKENSimCAECrashReporter.exe 上传崩溃报告用的 Bearer Token。
6. CRASH_SYMBOL_TOKENCI 上传 symbols.zip 用的 Bearer Token。 6. CRASH_SYMBOL_TOKENCI 上传 symbols.zip 用的 Bearer Token。
7. CRASH_ADMIN_TOKEN:查询和下载崩溃原始文件用;如果不配置,就复用当前管理员令牌。 7. CRASH_ADMIN_TOKEN:查询和下载崩溃原始文件用;如果不配置,就复用 ADMIN_TOKEN 作为崩溃报告管理兜底令牌。
简单理解: 简单理解:
@@ -1257,8 +1331,8 @@ SDK 是 Software Development Kit,中文通常叫“软件开发工具包”。
1. 可直接使用的二进制文件,例如 Launcher.exe、Updater.exe、Bootstrap.exe。 1. 可直接使用的二进制文件,例如 Launcher.exe、Updater.exe、Bootstrap.exe。
2. 必要的运行时文件。对 SimCAE 这种自身已带 Qt 的软件,SDK 默认不再携带 Qt DLL,避免覆盖业务软件原有运行库。 2. 必要的运行时文件。对 SimCAE 这种自身已带 Qt 的软件,SDK 默认不再携带 Qt DLL,避免覆盖业务软件原有运行库。
3. 配置模板,例如 app_config.example.json。 3. 配置模板,例如 app_config.example.json 和 server_config.json
4. 接入文档,例如如何配置 app_id、channel、api_base_url、license_key。 4. 接入文档,例如如何配置 app_id、channel、qrc 服务端地址、license_key。
5. 打包脚本,例如 package-client.ps1。 5. 打包脚本,例如 package-client.ps1。
6. 示例项目或 Demo。 6. 示例项目或 Demo。
7. API/命令行约定,例如主程序必须由 Launcher 启动,MainApp 需要写健康标记。 7. API/命令行约定,例如主程序必须由 Launcher 启动,MainApp 需要写健康标记。
@@ -1305,6 +1379,8 @@ UpdateClientSDK/
Bootstrap.exe Bootstrap.exe
config/ config/
app_config.json app_config.json
server_config.json
server_config.qrc
manifest_public_key.pem manifest_public_key.pem
scripts/ scripts/
install-sdk.ps1 install-sdk.ps1
@@ -1313,19 +1389,20 @@ UpdateClientSDK/
对接方真正拿到后,通常只需要做这些事: 对接方真正拿到后,通常只需要做这些事:
1. 把 SDK 的 Launcher、Updater、Bootstrap 放到业务软件运行目录。 1. 把 SDK 的 Launcher、Updater、Bootstrap 放到业务软件运行目录。
2. 设置 app_config.jsonserver 地址、app_id、channel、当前版本、主程序名等。`license_key` 可以预先填入;如果为空,用户首次启动 Launcher 时会弹窗粘贴授权密钥。 2. 设置 app_config.jsonapp_id、channel、当前版本、主程序名等。`license_key` 可以预先填入;如果为空,用户首次启动 Launcher 时会弹窗粘贴授权密钥。
3. 以后让用户启动 Launcher.exe 3. 设置 config/server_config.json:服务端 API 地址。该文件必须在编译 Launcher/Updater 前写好,因为它会被 qrc 编进程序
4. 在管理后台创建应用、License、渠道和发布版本 4. 以后让用户启动 Launcher.exe
5. 发布新版本时选择干净的 Release 输出目录,或切换到“压缩发布包”模式上传 zip/tar.gz/tar.bz2/rar 等压缩发布包 5. 在管理后台创建应用、License、渠道和发布版本
6. 发布新版本时选择干净的 Release 输出目录,或切换到“压缩发布包”模式上传 zip/tar.gz/tar.bz2/rar 等压缩发布包。
10.4 当前已有 package-client.ps1 的作用 10.4 当前已有 package-client.ps1 的作用
client/package-client.ps1 已经是 SDK/客户端包雏形。 update-client/scripts/package-client.ps1 已经是 SDK/客户端包雏形。
它目前会做这些事: 它目前会做这些事:
1. 从 out/bin 收集已编译好的客户端文件。 1. 从 out/bin 收集已编译好的客户端文件。
2. 检查必填配置,例如 app_id、channel、api_base_url、current_version、client_token、launch_token、license_key、主程序名等。 2. 检查必填配置,例如 app_id、channel、current_version、client_token、launch_token、license_key、主程序名等。
3. 检查 Launcher、Updater、Bootstrap、MainApp 是否存在。 3. 检查 Launcher、Updater、Bootstrap、MainApp 是否存在。
4. 拒绝 PDB、ILK、Debug Qt DLL 等调试产物进入发布包。 4. 拒绝 PDB、ILK、Debug Qt DLL 等调试产物进入发布包。
5. 检查主程序是否被嵌套放错目录。 5. 检查主程序是否被嵌套放错目录。
@@ -1357,11 +1434,11 @@ client/package-client.ps1 已经是 SDK/客户端包雏形。
- app_id=mycad - app_id=mycad
- channel=stable - channel=stable
- main_executable=MyCAD.exe - main_executable=MyCAD.exe
- api_base_url=http://服务器IP:8000
- license_key=服务端生成的授权密钥 - license_key=服务端生成的授权密钥
6. 用户桌面快捷方式指向 Launcher.exe 6. config/server_config.json 里写 `api_base_url=http://服务器IP:8000`,并重新编译 Launcher/Updater
7. 管理员以后在后台发布 MyCAD 的新版本 7. 用户桌面快捷方式指向 Launcher.exe
8. 用户启动 Launcher.exe 时自动检查、下载、替换并启动 MyCAD.exe 8. 管理员以后在后台发布 MyCAD 的新版本
9. 用户启动 Launcher.exe 时自动检查、下载、替换并启动 MyCAD.exe。
这就是“把客户端升级能力作为 SDK 给其他软件使用”。 这就是“把客户端升级能力作为 SDK 给其他软件使用”。
@@ -1369,7 +1446,7 @@ client/package-client.ps1 已经是 SDK/客户端包雏形。
Docker 可以理解为“把服务端运行环境打包成一个标准盒子”。 Docker 可以理解为“把服务端运行环境打包成一个标准盒子”。
本机直接运行 server/venv/bin/python3 main.py 时,依赖的是当前机器上的 Python 虚拟环境、当前目录、当前配置文件。 本机直接运行 update-server/venv/bin/python3 main.py 时,依赖的是当前机器上的 Python 虚拟环境、当前目录、当前配置文件。
Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器里的 /app 目录和 docker-compose.yml 映射进去的数据目录。 Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器里的 /app 目录和 docker-compose.yml 映射进去的数据目录。
@@ -1383,17 +1460,17 @@ Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器
10.7 当前服务端 Docker 已经具备什么 10.7 当前服务端 Docker 已经具备什么
当前 server/Dockerfile 已经具备: 当前 update-server/Dockerfile 已经具备:
1. 使用 python:3.12-slim 作为基础镜像。 1. 使用 python:3.12-slim 作为基础镜像。
2. 安装 server/requirements.txt 里的 FastAPI、MinIO、cryptography 等依赖。 2. 安装 update-server/requirements.txt 里的 FastAPI、MinIO、cryptography 等依赖。
3. 拷贝 main.py、db.py、minio_tool.py、tables.sql。 3. 拷贝 main.py、db.py、minio_tool.py、tables.sql。
4. 拷贝 server/admin.html 到容器内 /app/admin.html 4. 拷贝 admin-ui/dist 管理后台静态文件到容器内 /app/admin-ui/dist
5. 使用非 root 用户 updateapp 运行。 5. 使用非 root 用户 updateapp 运行。
6. 暴露 8000 端口。 6. 暴露 8000 端口。
7. 提供 healthcheck。 7. 提供 healthcheck。
当前 server/docker-compose.yml 已经具备: 当前 update-server/docker-compose.yml 已经具备:
1. minio:对象存储,保存升级文件。 1. minio:对象存储,保存升级文件。
2. minio-init:首次启动时创建 bucket。 2. minio-init:首次启动时创建 bucket。
@@ -1411,7 +1488,7 @@ Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器
1. 进入 server 目录。 1. 进入 server 目录。
2. 准备 .env。 2. 准备 .env。
3. 设置 ADMIN_TOKEN、CLIENT_API_TOKEN、MINIO_ACCESS_KEY、MINIO_SECRET_KEY、MINIO_BUCKET、MINIO_PUBLIC_ENDPOINT、CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN。 3. 设置 ADMIN_USERNAME、ADMIN_PASSWORD、ADMIN_JWT_SECRET、ADMIN_TOKEN、CLIENT_API_TOKEN、MINIO_ACCESS_KEY、MINIO_SECRET_KEY、MINIO_BUCKET、MINIO_PUBLIC_ENDPOINT、CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN。
4. 准备签名私钥 keys/manifest_private_key.pem。 4. 准备签名私钥 keys/manifest_private_key.pem。
5. 准备 runtime、minio_data 等持久化目录。 5. 准备 runtime、minio_data 等持久化目录。
6. 执行 docker compose build。 6. 执行 docker compose build。
@@ -1422,7 +1499,7 @@ Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器
示例命令: 示例命令:
cd server cd update-server
cp -n .env.example .env cp -n .env.example .env
mkdir -p runtime minio_data keys mkdir -p runtime minio_data keys
docker compose config docker compose config
@@ -1477,8 +1554,8 @@ Docker 是给“服务端部署人员”用的。
建议下一步做两个包: 建议下一步做两个包:
1. server/docker 部署包:给运维或服务器部署人员。 1. update-server/docker 部署包:给运维或服务器部署人员。
2. client/UpdateClientSDK.zip:给其他软件开发人员。 2. update-client/UpdateClientSDK.zip:给其他软件开发人员。
10.11 现在具体怎么做 10.11 现在具体怎么做
@@ -1488,7 +1565,7 @@ Docker 是给“服务端部署人员”用的。
1. 进入 server 目录。 1. 进入 server 目录。
2. 复制 .env.example 为 .env。 2. 复制 .env.example 为 .env。
3. 修改 ADMIN_TOKEN、CLIENT_API_TOKEN、MINIO_ACCESS_KEY、MINIO_SECRET_KEY、MINIO_BUCKET、MINIO_PUBLIC_ENDPOINT、CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN。 3. 修改 ADMIN_USERNAME、ADMIN_PASSWORD、ADMIN_JWT_SECRET、ADMIN_TOKEN、CLIENT_API_TOKEN、MINIO_ACCESS_KEY、MINIO_SECRET_KEY、MINIO_BUCKET、MINIO_PUBLIC_ENDPOINT、CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN。
4. 准备 keys/manifest_private_key.pem。 4. 准备 keys/manifest_private_key.pem。
5. 执行 docker compose config,确认配置无误。 5. 执行 docker compose config,确认配置无误。
6. 执行 docker compose build。 6. 执行 docker compose build。
@@ -1499,21 +1576,21 @@ Docker 是给“服务端部署人员”用的。
第二步:准备客户端 SDK 包。 第二步:准备客户端 SDK 包。
1. 确认 client/out/bin 是 Release 输出目录。 1. 确认 update-client/out/bin 是 Release 输出目录。
2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe。SimCAE 场景下不要把 SDK 自带 Qt DLL 覆盖到 SimCAE 的 bin 目录。 2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe。SimCAE 场景下不要把 SDK 自带 Qt DLL 覆盖到 SimCAE 的 bin 目录。
3. 确认 config/manifest_public_key.pem 是服务端私钥对应的公钥。 3. 确认 config/manifest_public_key.pem 是服务端私钥对应的公钥。
4. 填好 client/config/app_config.example.json 里的示例字段。 4. 填好 update-client/config/app_config.example.json 里的示例字段。
5. 在 Windows PowerShell 执行: 5. 在 Windows PowerShell 执行:
cd client cd update-client
.\package-sdk.ps1 -SourceDir .\out\bin -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0 .\scripts\package-sdk.ps1 -SourceDir .\out\bin -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
第三步:给接入方一个最小验证方式。 第三步:给接入方一个最小验证方式。
1. 解压 UpdateClientSDK.zip。 1. 解压 UpdateClientSDK.zip。
2. 把接入方自己的 YourApp.exe 放到 bin 目录同级的最终产品目录中。 2. 把接入方自己的 YourApp.exe 放到 bin 目录同级的最终产品目录中。
3. 把 config/app_config.example.json 复制成 config/app_config.json。 3. 把 config/app_config.example.json 复制成 config/app_config.json。
4. 修改 app_id、channel、api_base_url、client_token、license_key、main_executable。 4. 修改 app_id、channel、client_token、license_key、main_executable,并确认 SDK 二进制已用正确的 config/server_config.json 编译
5. 从 Launcher.exe 启动。 5. 从 Launcher.exe 启动。
6. 确认能登记设备、拉取策略、启动业务主程序。 6. 确认能登记设备、拉取策略、启动业务主程序。
@@ -1521,12 +1598,12 @@ cd client
SDK 是给开发者看的;具体产品给客户安装时,使用 package-client.ps1 SDK 是给开发者看的;具体产品给客户安装时,使用 package-client.ps1
cd client cd update-client
.\package-client.ps1 -SourceDir .\out\bin -ConfigFile .\config\app_config.json -OutputDir .\dist\UpdateClient -ZipFile .\dist\UpdateClient.zip .\scripts\package-client.ps1 -SourceDir .\out\bin -ConfigFile .\config\app_config.json -OutputDir .\dist\UpdateClient -ZipFile .\dist\UpdateClient.zip
第五步:补接入方文档和 Demo。 第五步:补接入方文档和 Demo。
1. SDK 根目录只保留一个 Word 接入说明作为入口,打开包后一眼能看到 1. SDK 包内提供 Docs/00-先读我-客户端文档入口.txt 作为默认入口;Word 说明作为可选增强
2. 准备一个最小 MainApp 示例,演示 --ticket-file 和 --health-file。 2. 准备一个最小 MainApp 示例,演示 --ticket-file 和 --health-file。
3. 准备一份常见错误说明。 3. 准备一份常见错误说明。
4. 准备一份服务端 Docker 部署说明。 4. 准备一份服务端 Docker 部署说明。