Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f02e34525d | |||
| 6b018272db |
@@ -0,0 +1,8 @@
|
|||||||
|
[submodule "server"]
|
||||||
|
path = server
|
||||||
|
url = https://git.alimzs.com:6443/nikelaluo/update-server.git
|
||||||
|
branch = master
|
||||||
|
[submodule "client"]
|
||||||
|
path = client
|
||||||
|
url = https://git.alimzs.com:6443/nikelaluo/update-client.git
|
||||||
|
branch = master
|
||||||
Submodule
+1
Submodule client added at b06e003502
@@ -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/
|
|
||||||
@@ -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)
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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(); }
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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()
|
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
#include <QWidget>
|
|
||||||
#include <QLabel>
|
|
||||||
#include <QVBoxLayout>
|
|
||||||
|
|
||||||
class MainWindow : public QWidget
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
public:
|
|
||||||
explicit MainWindow(QWidget* parent = nullptr);
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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 的发布源目录。
|
|
||||||
@@ -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.
@@ -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和Qt,OpenSSL库由Common INTERFACE自动传递
|
|
||||||
target_link_libraries(Updater PRIVATE
|
|
||||||
Common
|
|
||||||
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
|
||||||
)
|
|
||||||
|
|
||||||
# Qt部署脚本
|
|
||||||
if(WIN32)
|
|
||||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
|
||||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
|
||||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
|
||||||
add_custom_command(TARGET Updater POST_BUILD
|
|
||||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
|
|
||||||
)
|
|
||||||
endif()
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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-----
|
|
||||||
@@ -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."
|
|
||||||
@@ -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"
|
|
||||||
@@ -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.
Binary file not shown.
Submodule
+1
Submodule server added at 9a6ac1e4ed
@@ -1,8 +0,0 @@
|
|||||||
**
|
|
||||||
!Dockerfile
|
|
||||||
!requirements.txt
|
|
||||||
!main.py
|
|
||||||
!db.py
|
|
||||||
!minio_tool.py
|
|
||||||
!tables.sql
|
|
||||||
!admin.html
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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);
|
|
||||||
@@ -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/客户端包。
|
|
||||||
Reference in New Issue
Block a user