1.0.0
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
|||||||
|
# Operating systems and editors
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
**/.vs/
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# Generic temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Client: CMake / Visual Studio build outputs
|
||||||
|
client/out/
|
||||||
|
client/build/
|
||||||
|
client/build-*/
|
||||||
|
client/cmake-build-*/
|
||||||
|
client/.cmake/
|
||||||
|
client/CMakeSettings.json
|
||||||
|
client/CMakeUserPresets.json
|
||||||
|
client/Testing/
|
||||||
|
|
||||||
|
# Client: local runtime state and credentials
|
||||||
|
client/config/app_config.json
|
||||||
|
client/config/client_identity.dat
|
||||||
|
client/config/*.local.json
|
||||||
|
client/update/
|
||||||
|
client/update_temp/
|
||||||
|
|
||||||
|
# C/C++ generated artifacts outside build directories
|
||||||
|
*.obj
|
||||||
|
*.o
|
||||||
|
*.pdb
|
||||||
|
*.ilk
|
||||||
|
*.idb
|
||||||
|
*.tlog
|
||||||
|
*.lastbuildstate
|
||||||
|
*.exp
|
||||||
|
*.lib
|
||||||
|
*.dll
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Keep source-controlled PEM public keys; never commit private keys
|
||||||
|
server/keys/*private*.pem
|
||||||
|
server/keys/*private*.key
|
||||||
|
|
||||||
|
# Server: Python environments and caches
|
||||||
|
server/venv/
|
||||||
|
server/.venv/
|
||||||
|
server/__pycache__/
|
||||||
|
server/**/*.py[cod]
|
||||||
|
server/.pytest_cache/
|
||||||
|
server/.mypy_cache/
|
||||||
|
server/.ruff_cache/
|
||||||
|
server/.coverage
|
||||||
|
server/htmlcov/
|
||||||
|
|
||||||
|
# Server: local configuration and secrets
|
||||||
|
server/.env
|
||||||
|
server/.env.*
|
||||||
|
!server/.env.example
|
||||||
|
server/admin_token.sha256
|
||||||
|
|
||||||
|
# Server: runtime databases, uploads and object storage
|
||||||
|
server/*.db
|
||||||
|
server/*.db-journal
|
||||||
|
server/*.db-wal
|
||||||
|
server/*.db-shm
|
||||||
|
server/local_uploads/
|
||||||
|
server/minio_data/
|
||||||
|
server/minio
|
||||||
|
|
||||||
|
# Server: runtime files
|
||||||
|
server/*.pid
|
||||||
|
server/*.log
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
project(Bootstrap LANGUAGES CXX)
|
||||||
|
|
||||||
|
add_executable(Bootstrap WIN32 main.cpp)
|
||||||
|
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
||||||
|
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
|
||||||
|
target_link_libraries(Bootstrap PRIVATE shell32)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(ClientAll LANGUAGES C CXX)
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
if(MSVC)
|
||||||
|
add_compile_options(/utf-8)
|
||||||
|
endif()
|
||||||
|
# Qt全局配置
|
||||||
|
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
set(CMAKE_AUTOUIC ON)
|
||||||
|
set(CMAKE_AUTORCC ON)
|
||||||
|
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
|
||||||
|
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
||||||
|
# ========== OpenSSL 手动配置(完全抛弃find_package) ==========
|
||||||
|
set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64")
|
||||||
|
set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include")
|
||||||
|
set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd")
|
||||||
|
set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD")
|
||||||
|
# 全局宏,所有子项目统一启用OpenSSL
|
||||||
|
add_compile_definitions(HAVE_OPENSSL=1)
|
||||||
|
# ==========================================================
|
||||||
|
# 统一输出目录
|
||||||
|
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
|
||||||
|
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||||
|
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||||
|
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库
|
||||||
|
add_subdirectory(Bootstrap)
|
||||||
|
# 先编译公共库
|
||||||
|
add_subdirectory(Common)
|
||||||
|
# 再编译三个业务程序
|
||||||
|
add_subdirectory(Launcher)
|
||||||
|
add_subdirectory(Updater)
|
||||||
|
add_subdirectory(MainApp)
|
||||||
|
# 默认启动项目
|
||||||
|
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||||
|
# Copy client.ini and config directory to output bin folder
|
||||||
|
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
|
||||||
|
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
|
||||||
|
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
|
||||||
|
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
|
||||||
|
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
|
||||||
|
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
|
||||||
|
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
|
||||||
|
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
|
||||||
|
configure_file("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
|
||||||
|
endif()
|
||||||
|
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
|
||||||
|
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
|
||||||
|
configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
# 编译阶段同步静态配置资源
|
||||||
|
add_custom_target(copy_client_resources ALL
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem
|
||||||
|
COMMENT "Copy client config directory and manifest public key to output bin folder"
|
||||||
|
)
|
||||||
|
add_dependencies(Launcher copy_client_resources)
|
||||||
|
add_dependencies(Updater copy_client_resources)
|
||||||
|
add_dependencies(MainApp copy_client_resources)
|
||||||
|
# Install rules for packaging
|
||||||
|
if(EXISTS ${CONFIG_SOURCE_DIR})
|
||||||
|
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config)
|
||||||
|
endif()
|
||||||
|
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE})
|
||||||
|
install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
#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::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);
|
||||||
|
QFile input(m_configPath);
|
||||||
|
QJsonObject config;
|
||||||
|
if (input.exists())
|
||||||
|
{
|
||||||
|
if (!input.open(QIODevice::ReadOnly))
|
||||||
|
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())
|
||||||
|
return false;
|
||||||
|
config = document.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
config.insert(key, value);
|
||||||
|
QDir().mkpath(QFileInfo(m_configPath).path());
|
||||||
|
QSaveFile output(m_configPath);
|
||||||
|
if (!output.open(QIODevice::WriteOnly))
|
||||||
|
return false;
|
||||||
|
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||||
|
if (output.write(payload) != payload.size())
|
||||||
|
{
|
||||||
|
output.cancelWriting();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return output.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
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", "launch_token");
|
||||||
|
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");
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#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;
|
||||||
|
|
||||||
|
private:
|
||||||
|
ConfigHelper();
|
||||||
|
bool migrateLegacyIniIfNeeded();
|
||||||
|
QString m_configPath;
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#include "HttpHelper.h"
|
||||||
|
#include <QNetworkProxy>
|
||||||
|
#include "ConfigHelper.h"
|
||||||
|
|
||||||
|
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());
|
||||||
|
|
||||||
|
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
|
||||||
|
qDebug() << "=== POST Request ===";
|
||||||
|
qDebug() << "Url:" << url;
|
||||||
|
qDebug() << "Body:" << data;
|
||||||
|
|
||||||
|
QNetworkReply* reply = manager->post(req, data);
|
||||||
|
QEventLoop loop;
|
||||||
|
|
||||||
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
|
loop.exec();
|
||||||
|
|
||||||
|
int retCode = 0;
|
||||||
|
QJsonObject retObj;
|
||||||
|
|
||||||
|
if (reply->error() != QNetworkReply::NoError)
|
||||||
|
{
|
||||||
|
qDebug() << "Network error code:" << reply->error();
|
||||||
|
qDebug() << "Error detail:" << reply->errorString();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
QByteArray respData = reply->readAll();
|
||||||
|
qDebug() << "Server raw response:" << respData;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(respData);
|
||||||
|
retObj = doc.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(retCode, retObj);
|
||||||
|
|
||||||
|
reply->deleteLater();
|
||||||
|
manager->deleteLater();
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#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);
|
||||||
|
};
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#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::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(); }
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#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 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.20)
|
||||||
|
project(LauncherDemo LANGUAGES C CXX)
|
||||||
|
# C++标准
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
# Qt 自动处理ui/moc/rcc
|
||||||
|
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
set(CMAKE_AUTOUIC ON)
|
||||||
|
set(CMAKE_AUTORCC ON)
|
||||||
|
# 依赖Qt模块:网络、基础核心、窗口
|
||||||
|
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
|
||||||
|
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
||||||
|
# 所有源码文件
|
||||||
|
set(SRC_LIST
|
||||||
|
main.cpp
|
||||||
|
UpdateLogic.h
|
||||||
|
UpdateLogic.cpp
|
||||||
|
)
|
||||||
|
# 生成可执行程序
|
||||||
|
add_executable(Launcher ${SRC_LIST})
|
||||||
|
if(WIN32)
|
||||||
|
set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 关键:添加OpenSSL头文件目录
|
||||||
|
target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
|
||||||
|
|
||||||
|
# 链接Common,自动带上OpenSSL库
|
||||||
|
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
|
||||||
|
# 输出编译产物到 out 文件夹(干净不乱)
|
||||||
|
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
|
||||||
|
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||||
|
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
||||||
|
# 强制VS打开CMake文件夹时默认选中该可执行目标
|
||||||
|
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||||
|
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
|
||||||
|
if(WIN32)
|
||||||
|
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||||
|
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||||
|
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||||
|
add_custom_command(TARGET Launcher POST_BUILD
|
||||||
|
COMMAND ${WINDEPLOYQT} --debug $<TARGET_FILE:Launcher>
|
||||||
|
COMMENT "自动部署Qt依赖dll"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#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;
|
||||||
|
|
||||||
|
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["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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
#include <windows.h>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QProgressDialog>
|
||||||
|
#include <QTextCodec>
|
||||||
|
#include "UpdateLogic.h"
|
||||||
|
#include "../Common/ConfigHelper.h"
|
||||||
|
#include "../Common/PolicyHelper.h"
|
||||||
|
#include "../Common/LocalStateHelper.h"
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
|
const QString launchToken = config.getValue("App", "launch_token");
|
||||||
|
const QString currentVersion = config.getValue("App", "current_version");
|
||||||
|
const QString appDir = QApplication::applicationDirPath();
|
||||||
|
const QString mainAppPath = appDir + "/MainApp.exe";
|
||||||
|
const QString updaterPath = appDir + "/Updater.exe";
|
||||||
|
const QStringList mainArgs{QString("--launcher-token=%1").arg(launchToken)};
|
||||||
|
|
||||||
|
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 (!QProcess::startDetached(mainAppPath, mainArgs)) {
|
||||||
|
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 && !policy.isOfflineAllowed())
|
||||||
|
{
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
|
||||||
|
QApplication::processEvents();
|
||||||
|
if (!QProcess::startDetached(mainAppPath, mainArgs))
|
||||||
|
{
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
progress.close();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
project(MainApp)
|
||||||
|
add_executable(MainApp
|
||||||
|
main.cpp
|
||||||
|
MainWindow.h
|
||||||
|
MainWindow.cpp
|
||||||
|
../Common/ConfigHelper.h
|
||||||
|
)
|
||||||
|
target_include_directories(MainApp
|
||||||
|
PUBLIC ${CMAKE_SOURCE_DIR}/Common
|
||||||
|
PRIVATE ${OPENSSL_INC}
|
||||||
|
)
|
||||||
|
target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||||
|
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||||
|
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||||
|
add_custom_command(TARGET MainApp POST_BUILD
|
||||||
|
COMMAND ${WINDEPLOYQT} --debug $<TARGET_FILE:MainApp>
|
||||||
|
)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
|
class MainWindow : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit MainWindow(QWidget* parent = nullptr);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#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"
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const QString validToken = ConfigHelper::instance().getValue("App", "launch_token");
|
||||||
|
bool pass = false;
|
||||||
|
QString healthFilePath;
|
||||||
|
|
||||||
|
for (int i = 0; i < argc; ++i)
|
||||||
|
{
|
||||||
|
QString arg(argv[i]);
|
||||||
|
if (arg.startsWith("--launcher-token="))
|
||||||
|
{
|
||||||
|
QString tk = arg.split("=").last();
|
||||||
|
if (tk == validToken)
|
||||||
|
{
|
||||||
|
pass = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (arg.startsWith("--health-file="))
|
||||||
|
{
|
||||||
|
healthFilePath = arg.mid(QString("--health-file=").size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!pass)
|
||||||
|
{
|
||||||
|
QMessageBox::critical(nullptr, "Startup Restriction", "Direct double-clicking MainApp.exe is prohibited. Please use Launcher.exe to open the software!");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString appDir = QApplication::applicationDirPath();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
client目录下的CMakeList.txt用来统一管理client下的Launcher工程和MainApp工程和Updater工程
|
||||||
|
|
||||||
|
cofig.ini要跟exe同级目录
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
||||||
|
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
||||||
|
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
||||||
|
# 关闭VS自动Win32设计时预生成
|
||||||
|
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
||||||
|
# 清除32位Strawberry Perl路径干扰
|
||||||
|
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include")
|
||||||
|
list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib")
|
||||||
|
|
||||||
|
project(Updater)
|
||||||
|
set(SRC
|
||||||
|
main.cpp
|
||||||
|
UpdaterLogic.h
|
||||||
|
UpdaterLogic.cpp
|
||||||
|
UpdateTransaction.h
|
||||||
|
UpdateTransaction.cpp
|
||||||
|
)
|
||||||
|
add_executable(Updater ${SRC})
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
|
||||||
|
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
|
||||||
|
|
||||||
|
# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递
|
||||||
|
target_link_libraries(Updater PRIVATE
|
||||||
|
Common
|
||||||
|
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
||||||
|
)
|
||||||
|
|
||||||
|
# Qt部署脚本
|
||||||
|
if(WIN32)
|
||||||
|
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||||
|
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||||
|
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||||
|
add_custom_command(TARGET Updater POST_BUILD
|
||||||
|
COMMAND ${WINDEPLOYQT} --debug $<TARGET_FILE:Updater>
|
||||||
|
)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
#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& fromVersion,
|
||||||
|
const QString& toVersion, int manifestId)
|
||||||
|
: m_installDir(QDir::cleanPath(installDir)),
|
||||||
|
m_updateDir(QDir(installDir).filePath("update")),
|
||||||
|
m_stateFile(QDir(installDir).filePath("update/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, QString* restoredVersion, QString* errorMessage)
|
||||||
|
{
|
||||||
|
const QString stateFile = QDir(installDir).filePath("update/upgrade_state.json");
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QJsonObject>
|
||||||
|
|
||||||
|
class UpdateTransaction
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
UpdateTransaction(const QString& installDir, 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,
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
#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 <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());
|
||||||
|
}
|
||||||
|
|
||||||
|
const 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")
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
static const 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")
|
||||||
|
};
|
||||||
|
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(targetDir).filePath("update/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(targetDir).filePath("update/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::reportResult(const QString& deviceId,
|
||||||
|
const QString& fromVer,
|
||||||
|
const QString& toVer,
|
||||||
|
bool success)
|
||||||
|
{
|
||||||
|
QString url = m_serverAddr + "/api/v1/update/report";
|
||||||
|
|
||||||
|
QJsonObject body;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
QElapsedTimer m_downloadTimer;
|
||||||
|
};
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
#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"
|
||||||
|
|
||||||
|
int main(int argc, char* argv[])
|
||||||
|
{
|
||||||
|
SetConsoleOutputCP(65001);
|
||||||
|
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
QApplication::setApplicationName("Marsco Updater");
|
||||||
|
|
||||||
|
if (argc < 5) {
|
||||||
|
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少应用、渠道或目标版本参数,请从 Launcher 启动。");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString appId = argv[1];
|
||||||
|
const QString channel = argv[2];
|
||||||
|
const QString targetVersion = argv[3];
|
||||||
|
const int 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 targetDir = QApplication::applicationDirPath();
|
||||||
|
|
||||||
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
|
QString fromVersion = config.getValue("App", "current_version");
|
||||||
|
QString deviceId = config.getValue("Update", "device_id");
|
||||||
|
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
||||||
|
|
||||||
|
if (!resumingFromBootstrap) {
|
||||||
|
QString restoredVersion;
|
||||||
|
QString recoveryError;
|
||||||
|
if (!UpdateTransaction::recoverInterrupted(targetDir, &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();
|
||||||
|
|
||||||
|
UpdaterLogic logic;
|
||||||
|
UpdateTransaction transaction(targetDir, 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 fail = [&](const QString& title, const QString& message,
|
||||||
|
const QString& errorCode = QString("update_failed")) {
|
||||||
|
transaction.markFailed(errorCode, message);
|
||||||
|
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, title, message);
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
const QString mainAppPath = QDir(targetDir).filePath("MainApp.exe");
|
||||||
|
const QString updaterPath = QDir(targetDir).filePath("Updater.exe");
|
||||||
|
const QString bootstrapPath = QDir(targetDir).filePath("Bootstrap.exe");
|
||||||
|
const QString launchToken = config.getValue("App", "launch_token");
|
||||||
|
const auto launchMainApp = [&](const QString& healthFile = QString()) {
|
||||||
|
QStringList args{QString("--launcher-token=%1").arg(launchToken)};
|
||||||
|
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
|
||||||
|
return QProcess::startDetached(mainAppPath, args);
|
||||||
|
};
|
||||||
|
const auto bootstrapPlanFile = [&]() {
|
||||||
|
return QDir(targetDir).filePath("update/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("MainApp.exe");
|
||||||
|
transaction.markRollbackRequired(reason);
|
||||||
|
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
||||||
|
QApplication::processEvents();
|
||||||
|
if (launchBootstrap("rollback")) {
|
||||||
|
progress.close();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, title,
|
||||||
|
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (resumingFromBootstrap) {
|
||||||
|
QString resumeError;
|
||||||
|
if (!transaction.resumeExisting(&resumeError)) {
|
||||||
|
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, "事务续办失败",
|
||||||
|
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
fromVersion = transaction.fromVersion();
|
||||||
|
if (bootstrapResult == "rolledback") {
|
||||||
|
const bool stateOk = config.setValue("App", "current_version", fromVersion);
|
||||||
|
transaction.markRolledBack();
|
||||||
|
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||||
|
if (stateOk) launchMainApp();
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::warning(nullptr, "更新已回滚",
|
||||||
|
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。"
|
||||||
|
: "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。");
|
||||||
|
return stateOk ? 0 : -1;
|
||||||
|
}
|
||||||
|
if (bootstrapResult != "success") {
|
||||||
|
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, "自动回滚失败",
|
||||||
|
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else if (!transaction.initialize()) {
|
||||||
|
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
setProgress(resumingFromBootstrap ? 72 : 10, "正在获取并验证版本清单...");
|
||||||
|
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
||||||
|
if (!logic.verifyManifestSignature()) {
|
||||||
|
if (resumingFromBootstrap)
|
||||||
|
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
||||||
|
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
||||||
|
}
|
||||||
|
const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache");
|
||||||
|
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, "正在获取安全下载地址...");
|
||||||
|
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||||
|
if (logic.getFileList().isEmpty())
|
||||||
|
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||||
|
|
||||||
|
QStorageInfo storage(targetDir);
|
||||||
|
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("正在下载并校验 %1 个版本文件...").arg(logic.getFileList().size()));
|
||||||
|
if (!logic.downloadAllFiles(stagingDir, targetDir))
|
||||||
|
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
|
||||||
|
|
||||||
|
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())));
|
||||||
|
for (const QString& path : changedPaths) {
|
||||||
|
if (path.compare("Bootstrap.exe", Qt::CaseInsensitive) == 0)
|
||||||
|
return fail("Bootstrap 无法自更新", "本次版本包含新的 Bootstrap.exe。请使用安装包升级 Bootstrap,再重新发布业务版本。", "bootstrap_self_update_blocked");
|
||||||
|
}
|
||||||
|
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||||
|
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||||
|
|
||||||
|
setProgress(66, "正在关闭主程序...");
|
||||||
|
if (!FileHelper::killProcess("MainApp.exe"))
|
||||||
|
return fail("无法关闭主程序", "MainApp.exe 仍在运行,请手动关闭后重试。", "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("启动失败", "MainApp.exe 无法启动。");
|
||||||
|
|
||||||
|
QElapsedTimer healthTimer;
|
||||||
|
healthTimer.start();
|
||||||
|
while (healthTimer.elapsed() < 15000 && !QFile::exists(healthFile)) {
|
||||||
|
QApplication::processEvents();
|
||||||
|
QThread::msleep(100);
|
||||||
|
}
|
||||||
|
if (!QFile::exists(healthFile))
|
||||||
|
return delegateRollback("启动确认失败", "新版本在 15 秒内没有完成启动健康确认。");
|
||||||
|
|
||||||
|
setProgress(99, "正在提交更新事务...");
|
||||||
|
if (!transaction.commit())
|
||||||
|
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。");
|
||||||
|
|
||||||
|
QFile::remove(healthFile);
|
||||||
|
QFile::remove(bootstrapPlanFile());
|
||||||
|
logic.reportResult(deviceId, fromVersion, targetVersion, true);
|
||||||
|
progress.setValue(100);
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Marsco 发布控制台</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, Helvetica, sans-serif; margin: 20px; background: #f4f6fb; color: #222; }
|
||||||
|
h1, h2 { margin: 0 0 12px; }
|
||||||
|
.section { background: #fff; border: 1px solid #d8dbe2; border-radius: 10px; padding: 16px; margin-bottom: 18px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
||||||
|
.row label { min-width: 110px; margin: 0; }
|
||||||
|
.row input, .row select, .row button { padding: 8px 10px; font-size: 14px; }
|
||||||
|
.row input, .row select { min-width: 240px; border: 1px solid #c9cdd5; border-radius: 6px; }
|
||||||
|
.row button { border: 1px solid #5a8efc; background: #5a8efc; color: #fff; border-radius: 6px; cursor: pointer; }
|
||||||
|
.row button:hover { background: #4b7ce6; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||||
|
th, td { border: 1px solid #e1e4ea; padding: 10px; text-align: left; vertical-align: middle; }
|
||||||
|
th { background: #f1f5fb; }
|
||||||
|
td button { margin-right: 6px; }
|
||||||
|
pre { background: #0f172a; color: #f8fafc; border-radius: 10px; padding: 14px; min-height: 120px; overflow-x: auto; }
|
||||||
|
.small { font-size: 13px; color: #6b7280; }
|
||||||
|
.wide { width: 100%; }
|
||||||
|
|
||||||
|
:root { --ink:#172033; --muted:#667085; --line:#e4e9f1; --paper:#fff; --brand:#3157d5; --brand-dark:#2447bc; --soft:#f4f6fa; --danger:#d92d20; --success:#067647; }
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { max-width:1180px; margin:0 auto 60px; padding:0 24px; font-family:Inter,"Microsoft YaHei",system-ui,-apple-system,sans-serif; color:var(--ink); background:var(--soft); font-size:14px; }
|
||||||
|
body::before { content:""; position:absolute; inset:0 0 auto; height:190px; z-index:-1; background:linear-gradient(120deg,#121a32,#294681); }
|
||||||
|
h1 { margin:0 -24px 28px; padding:32px 24px 55px; color:#fff; font-size:30px; letter-spacing:-.03em; }
|
||||||
|
h1::before { content:"MARSCO UPDATE SERVICE"; display:block; margin-bottom:7px; color:#adc0ff; font-size:11px; letter-spacing:.16em; }
|
||||||
|
h1::after { content:" · 软件发布与升级管理"; color:#d7e0ff; font-size:14px; font-weight:400; letter-spacing:0; }
|
||||||
|
h2 { margin:0; font-size:17px; }
|
||||||
|
.section { border:1px solid var(--line); border-radius:16px; padding:22px; margin-bottom:18px; box-shadow:0 8px 28px rgba(24,36,61,.06); }
|
||||||
|
.section h2 { padding-bottom:14px; margin-bottom:17px; border-bottom:1px solid #edf0f4; }
|
||||||
|
.row { gap:12px; margin-top:12px; align-items:flex-end; }
|
||||||
|
.row label { min-width:auto; color:#344054; font-size:13px; font-weight:650; }
|
||||||
|
.row input,.row select { min-width:220px; padding:10px 12px; border:1px solid #d0d5dd; border-radius:9px; outline:none; transition:.18s ease; }
|
||||||
|
.row input:focus,.row select:focus { border-color:#7893e8; box-shadow:0 0 0 3px rgba(49,87,213,.12); }
|
||||||
|
.row button,td button { padding:9px 13px; border:1px solid #d0d5dd; border-radius:9px; background:#fff; color:#344054; font-weight:650; cursor:pointer; transition:.16s ease; }
|
||||||
|
.row button:hover,td button:hover { transform:translateY(-1px); border-color:#98a2b3; box-shadow:0 3px 9px rgba(16,24,40,.08); }
|
||||||
|
#saveTokenBtn,#addAppBtn,#publishBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
|
||||||
|
#saveTokenBtn:hover,#addAppBtn:hover,#publishBtn:hover { background:var(--brand-dark); }
|
||||||
|
button:disabled { opacity:.55; cursor:not-allowed; transform:none!important; }
|
||||||
|
input[type=file] { padding:8px; background:#fafbfc; }
|
||||||
|
input[type=file]::file-selector-button { border:0; border-radius:7px; padding:7px 11px; margin-right:10px; color:#344054; background:#e9edf5; cursor:pointer; }
|
||||||
|
table { margin-top:14px; border-collapse:separate; border-spacing:0; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
|
||||||
|
th,td { padding:12px 14px; border:0; border-bottom:1px solid #edf0f4; }
|
||||||
|
th { color:#475467; background:#f8f9fb; font-size:12px; letter-spacing:.02em; }
|
||||||
|
tbody tr:last-child td { border-bottom:0; }
|
||||||
|
tbody tr:hover { background:#fafbff; }
|
||||||
|
td button[data-action=delete] { color:var(--danger); border-color:#fecdca; background:#fff7f6; }
|
||||||
|
.badge { display:inline-flex; border-radius:999px; padding:4px 8px; font-size:12px; font-weight:700; color:#475467; background:#f2f4f7; }
|
||||||
|
.badge.success { color:var(--success); background:#ecfdf3; }
|
||||||
|
.badge.fail { color:var(--danger); background:#fef3f2; }
|
||||||
|
.empty { padding:30px 12px!important; color:var(--muted); text-align:center; }
|
||||||
|
pre { margin:0; min-height:105px; max-height:320px; padding:15px; color:#d8e4ff; background:#11182a; border-radius:11px; font:12px/1.65 "Cascadia Code",Consolas,monospace; white-space:pre-wrap; }
|
||||||
|
.small { margin-top:10px; color:var(--muted); }
|
||||||
|
.token-change { margin-top:16px; padding:14px; border:1px solid #dce3f1; border-radius:11px; background:#f8faff; }
|
||||||
|
.token-change[hidden] { display:none; }
|
||||||
|
#toggleTokenBtn { min-width:64px; }
|
||||||
|
#changeTokenBtn { color:var(--brand); border-color:#b9c7ef; background:#f7f9ff; }
|
||||||
|
#confirmChangeTokenBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
|
||||||
|
.toast { position:fixed; right:22px; bottom:22px; z-index:20; max-width:380px; padding:12px 15px; color:#fff; background:#1d2939; border-radius:10px; box-shadow:0 12px 32px rgba(16,24,40,.25); opacity:0; transform:translateY(12px); pointer-events:none; transition:.22s ease; }
|
||||||
|
.toast.show { opacity:1; transform:translateY(0); }
|
||||||
|
.toast.error { background:#b42318; }
|
||||||
|
@media(max-width:760px){body{padding:0 12px}h1{margin:0 -12px 20px;padding:24px 12px 46px;font-size:24px}.section{padding:17px}.row{align-items:stretch;flex-direction:column}.row input,.row select,.row button{width:100%;min-width:0}table{display:block;overflow-x:auto;white-space:nowrap}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><div id="toast" class="toast"></div>
|
||||||
|
<h1>软件发布控制台</h1>
|
||||||
|
<div class="section">
|
||||||
|
<h2>管理员令牌</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label for="adminToken">X-Admin-Token</label>
|
||||||
|
<input id="adminToken" type="password" class="wide" autocomplete="off" placeholder="请输入管理员令牌" />
|
||||||
|
<button id="toggleTokenBtn" type="button" aria-label="显示令牌">显示</button>
|
||||||
|
<button id="saveTokenBtn" type="button">登录并保存</button>
|
||||||
|
<button id="changeTokenBtn" type="button">更改令牌</button>
|
||||||
|
</div>
|
||||||
|
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端只保存令牌哈希。</div>
|
||||||
|
<div id="tokenChangePanel" class="token-change" hidden>
|
||||||
|
<div class="row"><label for="newAdminToken">新令牌</label><input id="newAdminToken" type="password" autocomplete="new-password" placeholder="至少 8 个字符" /><label for="confirmAdminToken">确认新令牌</label><input id="confirmAdminToken" type="password" autocomplete="new-password" placeholder="再次输入新令牌" /><button id="confirmChangeTokenBtn" type="button">确认更改</button><button id="cancelChangeTokenBtn" type="button">取消</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>应用管理</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button id="refreshAppsBtn" type="button">刷新应用列表</button>
|
||||||
|
<label for="appSelect">选择应用</label>
|
||||||
|
<select id="appSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="newAppId">App ID</label>
|
||||||
|
<input id="newAppId" type="text" placeholder="例如 myapp" />
|
||||||
|
<label for="newAppName">App 名称</label>
|
||||||
|
<input id="newAppName" type="text" placeholder="例如 我的应用" />
|
||||||
|
<button id="addAppBtn" type="button">新增应用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>发布新版本</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label for="publishAppId">App ID</label>
|
||||||
|
<input id="publishAppId" type="text" placeholder="请选择应用或手动输入" />
|
||||||
|
<label for="publishVersion">版本号</label>
|
||||||
|
<input id="publishVersion" type="text" value="1.0.0" />
|
||||||
|
<label for="publishChannel">渠道</label>
|
||||||
|
<select id="publishChannel"><option value="stable">stable(正式版)</option><option value="preview">preview(预览版)</option><option value="dev">dev(开发版)</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="publishFiles">软件根目录</label>
|
||||||
|
<input id="publishFiles" type="file" webkitdirectory directory multiple />
|
||||||
|
<span id="publishFileSummary" class="small">请选择包含 MainApp.exe 的发布目录</span>
|
||||||
|
<button id="publishBtn" type="button">发布版本</button>
|
||||||
|
</div>
|
||||||
|
<pre id="publishFilePreview">尚未选择发布目录</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>版本列表</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button id="refreshVersionsBtn" type="button">刷新版本列表</button>
|
||||||
|
<span class="small">当前应用:<strong id="currentAppLabel">未选择</strong></span>
|
||||||
|
<span class="small">将历史版本设为最新时,仅在该渠道策略勾选“允许降级”后客户端才会回退。</span>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>渠道</th>
|
||||||
|
<th>最新</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="versionRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>版本运行策略</h2>
|
||||||
|
<div class="row">
|
||||||
|
<label for="policyChannel">渠道</label><select id="policyChannel"><option>stable</option><option>preview</option><option>dev</option></select>
|
||||||
|
<label><input id="policyForceUpdate" type="checkbox" /> 强制升级</label>
|
||||||
|
<label><input id="policyAllowRollback" type="checkbox" /> 允许降级</label>
|
||||||
|
<label><input id="policyOfflineAllowed" type="checkbox" checked /> 允许离线启动</label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="policyMinVersion">最低支持版本</label><input id="policyMinVersion" placeholder="留空表示不限制" />
|
||||||
|
<label for="policyValidUntil">离线有效期</label><input id="policyValidUntil" value="2099-12-31T23:59:59Z" />
|
||||||
|
<label for="policyDisabledVersions">禁用版本</label><input id="policyDisabledVersions" placeholder="多个版本用逗号分隔" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="policyMessage">提示信息</label><input id="policyMessage" class="wide" placeholder="客户端显示的策略提示" />
|
||||||
|
<button id="loadPolicyBtn" type="button">读取策略</button><button id="savePolicyBtn" type="button">保存并递增序列</button>
|
||||||
|
<span class="small">当前 policy_seq:<strong id="policySeq">-</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>升级日志</h2>
|
||||||
|
<div class="row">
|
||||||
|
<button id="refreshReportsBtn" type="button">刷新日志</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>设备</th>
|
||||||
|
<th>旧版本</th>
|
||||||
|
<th>新版本</th>
|
||||||
|
<th>结果</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="reportRows"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>调试输出</h2>
|
||||||
|
<pre id="output">准备就绪...</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const apiBase = 'http://' + (location.hostname || '127.0.0.1') + ':8000';
|
||||||
|
const adminTokenInput = document.getElementById('adminToken');
|
||||||
|
const toggleTokenBtn = document.getElementById('toggleTokenBtn');
|
||||||
|
const tokenChangePanel = document.getElementById('tokenChangePanel');
|
||||||
|
const newAdminTokenInput = document.getElementById('newAdminToken');
|
||||||
|
const confirmAdminTokenInput = document.getElementById('confirmAdminToken');
|
||||||
|
const out = document.getElementById('output');
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
const appSelect = document.getElementById('appSelect');
|
||||||
|
const currentAppLabel = document.getElementById('currentAppLabel');
|
||||||
|
const publishAppIdInput = document.getElementById('publishAppId');
|
||||||
|
const publishVersionInput = document.getElementById('publishVersion');
|
||||||
|
const publishChannelInput = document.getElementById('publishChannel');
|
||||||
|
const publishFilesInput = document.getElementById('publishFiles');
|
||||||
|
const publishFileSummary = document.getElementById('publishFileSummary');
|
||||||
|
const publishFilePreview = document.getElementById('publishFilePreview');
|
||||||
|
const publishBtn = document.getElementById('publishBtn');
|
||||||
|
const versionRows = document.getElementById('versionRows');
|
||||||
|
const reportRows = document.getElementById('reportRows');
|
||||||
|
|
||||||
|
let currentToken = localStorage.getItem('admin_token') || '';
|
||||||
|
let apps = [];
|
||||||
|
let currentAppId = '';
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
if (typeof message === 'string') {
|
||||||
|
out.textContent = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message instanceof Error) {
|
||||||
|
out.textContent = `${message.name}: ${message.message}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.textContent = JSON.stringify(message, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
let toastTimer;
|
||||||
|
function notify(message, isError = false) {
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.className = `toast show${isError ? " error" : ""}`;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => { toast.className = "toast"; }, 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHeaders(contentType) {
|
||||||
|
const headers = { 'X-Admin-Token': currentToken };
|
||||||
|
if (contentType) headers['Content-Type'] = contentType;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const url = apiBase + path;
|
||||||
|
const reqOptions = {
|
||||||
|
...options,
|
||||||
|
headers: { ...(options.headers || {}), ...getHeaders(options.contentType) },
|
||||||
|
};
|
||||||
|
delete reqOptions.contentType;
|
||||||
|
if (!reqOptions.body) delete reqOptions.body;
|
||||||
|
|
||||||
|
const resp = await fetch(url, reqOptions);
|
||||||
|
const text = await resp.text();
|
||||||
|
let body;
|
||||||
|
try { body = JSON.parse(text); } catch { body = text; }
|
||||||
|
if (!resp.ok) {
|
||||||
|
const error = new Error(`HTTP ${resp.status}: ${typeof body === 'string' ? body : JSON.stringify(body)}`);
|
||||||
|
error.status = resp.status;
|
||||||
|
error.body = body;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTokenInput() {
|
||||||
|
adminTokenInput.value = currentToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAppSelection() {
|
||||||
|
appSelect.innerHTML = '<option value="">请选择应用</option>';
|
||||||
|
apps.forEach(app => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = app.app_id;
|
||||||
|
option.textContent = `${app.app_id} (${app.app_name})`;
|
||||||
|
appSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
if (currentAppId) {
|
||||||
|
appSelect.value = currentAppId;
|
||||||
|
publishAppIdInput.value = currentAppId;
|
||||||
|
currentAppLabel.textContent = currentAppId;
|
||||||
|
} else {
|
||||||
|
currentAppLabel.textContent = '未选择';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
log('正在加载应用列表...');
|
||||||
|
try {
|
||||||
|
const data = await request('/admin/app/list');
|
||||||
|
apps = data.list || [];
|
||||||
|
updateAppSelection();
|
||||||
|
log({ apps });
|
||||||
|
} catch (err) {
|
||||||
|
log({ error: '加载应用列表失败', err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addApp() {
|
||||||
|
const appId = document.getElementById('newAppId').value.trim();
|
||||||
|
const appName = document.getElementById('newAppName').value.trim();
|
||||||
|
if (!appId || !appName) {
|
||||||
|
log('请输入 App ID 和 App 名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log('正在新增应用...');
|
||||||
|
try {
|
||||||
|
const body = await request('/admin/app/add', {
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ app_id: appId, app_name: appName }),
|
||||||
|
});
|
||||||
|
log(body);
|
||||||
|
document.getElementById('newAppId').value = '';
|
||||||
|
document.getElementById('newAppName').value = '';
|
||||||
|
await loadApps();
|
||||||
|
} catch (err) {
|
||||||
|
log({ error: '新增应用失败', err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function relativePathForFile(file) {
|
||||||
|
const raw = (file.webkitRelativePath || file.name).replaceAll(chrBackslash, '/');
|
||||||
|
const parts = raw.split('/').filter(Boolean);
|
||||||
|
if (file.webkitRelativePath && parts.length > 1) parts.shift();
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const chrBackslash = String.fromCharCode(92);
|
||||||
|
function selectedUploadEntries() {
|
||||||
|
const excludedFiles = new Set(['client.ini', 'bootstrap.exe', 'config/app_config.json', 'config/local_state.json', 'config/client_identity.dat', 'config/version_policy.dat']);
|
||||||
|
return Array.from(publishFilesInput.files).map(file => ({ file, path: relativePathForFile(file) })).filter(item => {
|
||||||
|
const path = item.path.toLowerCase();
|
||||||
|
return !excludedFiles.has(path) && !path.startsWith('update/') && !path.startsWith('update_temp/') && !path.endsWith('.pdb') && !path.endsWith('.ilk');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateReleaseRoot(entries) {
|
||||||
|
if (!entries.length) return '请选择软件发布根目录';
|
||||||
|
const lowerPaths = entries.map(item => item.path.toLowerCase());
|
||||||
|
if (!lowerPaths.includes('mainapp.exe')) {
|
||||||
|
const nestedMain = lowerPaths.find(path => path.endsWith('/mainapp.exe'));
|
||||||
|
if (nestedMain) {
|
||||||
|
const expectedRoot = nestedMain.slice(0, -'/mainapp.exe'.length);
|
||||||
|
return '选择层级过高:MainApp.exe 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
|
||||||
|
}
|
||||||
|
return '所选目录根级没有 MainApp.exe,不能作为软件发布根目录';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSelectedDirectory() {
|
||||||
|
const allCount = publishFilesInput.files.length;
|
||||||
|
const entries = selectedUploadEntries();
|
||||||
|
const totalBytes = entries.reduce((sum, item) => sum + item.file.size, 0);
|
||||||
|
const skipped = allCount - entries.length;
|
||||||
|
const validationError = validateReleaseRoot(entries);
|
||||||
|
publishBtn.disabled = Boolean(validationError);
|
||||||
|
publishFileSummary.textContent = validationError || (allCount ? ('将发布 ' + entries.length + ' 个文件,共 ' + (totalBytes / 1024 / 1024).toFixed(2) + ' MB;排除 ' + skipped + ' 个运行时/调试文件') : '请选择包含 MainApp.exe 的发布目录');
|
||||||
|
publishFileSummary.style.color = validationError ? '#d92d20' : '';
|
||||||
|
const paths = entries.slice(0, 30).map(item => item.path);
|
||||||
|
publishFilePreview.textContent = paths.length ? paths.join('\n') + (entries.length > 30 ? '\n... 其余 ' + (entries.length - 30) + ' 个文件' : '') : '尚未选择发布目录';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishVersion() {
|
||||||
|
const appId = publishAppIdInput.value.trim();
|
||||||
|
const version = publishVersionInput.value.trim();
|
||||||
|
const channel = publishChannelInput.value.trim() || 'stable';
|
||||||
|
const entries = selectedUploadEntries();
|
||||||
|
if (!appId) { log('请选择或填写 App ID'); return; }
|
||||||
|
if (!version) { log('请输入版本号'); return; }
|
||||||
|
const rootError = validateReleaseRoot(entries);
|
||||||
|
if (rootError) { log(rootError); notify(rootError, true); return; }
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('app_id', appId);
|
||||||
|
formData.append('version', version);
|
||||||
|
formData.append('channel', channel);
|
||||||
|
for (const item of entries) {
|
||||||
|
formData.append('files', item.file, item.file.name);
|
||||||
|
formData.append('relative_paths', item.path);
|
||||||
|
}
|
||||||
|
publishBtn.disabled = true;
|
||||||
|
const originalText = publishBtn.textContent;
|
||||||
|
publishBtn.textContent = '发布中...';
|
||||||
|
log('正在发布版本...');
|
||||||
|
try {
|
||||||
|
const body = await request('/admin/publish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Admin-Token': currentToken },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
log({ success: '发布完成', response: body });
|
||||||
|
await loadVersions();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('publishVersion error', err);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
log({
|
||||||
|
error: err.message,
|
||||||
|
status: err.status || 'unknown',
|
||||||
|
details: err.body || err,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log({ error: '发布失败', details: err });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
publishBtn.disabled = false;
|
||||||
|
publishBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
const appId = currentAppId || publishAppIdInput.value.trim();
|
||||||
|
if (!appId) {
|
||||||
|
log('请先选择应用或填写 App ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log('正在加载版本列表...');
|
||||||
|
try {
|
||||||
|
const data = await request(`/admin/version/list?app_id=${encodeURIComponent(appId)}`);
|
||||||
|
const rows = data.list || [];
|
||||||
|
versionRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">该应用还没有发布版本</td></tr>';
|
||||||
|
rows.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${item.id}</td>
|
||||||
|
<td>${item.version}</td>
|
||||||
|
<td>${item.channel}</td>
|
||||||
|
<td><span class="badge ${item.latest ? 'success' : ''}">${item.latest ? '当前最新' : '历史版本'}</span></td>
|
||||||
|
<td>${item.create_time || ''}</td>
|
||||||
|
<td>
|
||||||
|
<button type="button" data-action="set-latest" data-id="${item.id}">设为最新</button>
|
||||||
|
<button type="button" data-action="delete" data-id="${item.id}">删除</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
versionRows.appendChild(tr);
|
||||||
|
});
|
||||||
|
log({ versions: rows });
|
||||||
|
} catch (err) {
|
||||||
|
log({ error: '载入版本列表失败', err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setLatest(versionId) {
|
||||||
|
log(`正在设置版本 ${versionId} 为最新...`);
|
||||||
|
try {
|
||||||
|
const body = await request('/admin/version/set-latest', {
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ version_id: Number(versionId) }),
|
||||||
|
});
|
||||||
|
log(body);
|
||||||
|
await loadVersions();
|
||||||
|
} catch (err) {
|
||||||
|
log({ error: '设置最新版本失败', err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVersion(versionId) {
|
||||||
|
if (!window.confirm(`确定删除版本记录 #${versionId} 及其云端文件吗?此操作不可撤销。`)) return;
|
||||||
|
log(`正在删除版本 ${versionId} ...`);
|
||||||
|
try {
|
||||||
|
const body = await request('/admin/version/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ version_id: Number(versionId) }),
|
||||||
|
});
|
||||||
|
log(body);
|
||||||
|
await loadVersions();
|
||||||
|
} catch (err) {
|
||||||
|
log({ error: '删除版本失败', err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPolicy() {
|
||||||
|
const appId = currentAppId || publishAppIdInput.value.trim();
|
||||||
|
const channel = document.getElementById('policyChannel').value;
|
||||||
|
if (!appId) { notify('请先选择应用', true); return; }
|
||||||
|
try {
|
||||||
|
const p = await request(`/admin/policy?app_id=${encodeURIComponent(appId)}&channel=${encodeURIComponent(channel)}`);
|
||||||
|
document.getElementById('policyForceUpdate').checked = Boolean(p.force_update);
|
||||||
|
document.getElementById('policyAllowRollback').checked = Boolean(p.allow_rollback);
|
||||||
|
document.getElementById('policyOfflineAllowed').checked = Boolean(p.offline_allowed);
|
||||||
|
document.getElementById('policyMinVersion').value = p.min_supported_version || '';
|
||||||
|
document.getElementById('policyValidUntil').value = p.valid_until || '';
|
||||||
|
document.getElementById('policyDisabledVersions').value = (p.disabled_versions || []).join(', ');
|
||||||
|
document.getElementById('policyMessage').value = p.message || '';
|
||||||
|
document.getElementById('policySeq').textContent = p.policy_seq;
|
||||||
|
log({ policy: p });
|
||||||
|
} catch (err) { log(err); notify('读取策略失败', true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePolicy() {
|
||||||
|
const appId = currentAppId || publishAppIdInput.value.trim();
|
||||||
|
if (!appId) { notify('请先选择应用', true); return; }
|
||||||
|
const disabled = document.getElementById('policyDisabledVersions').value.split(',').map(v => v.trim()).filter(Boolean);
|
||||||
|
const body = {
|
||||||
|
app_id: appId, channel: document.getElementById('policyChannel').value,
|
||||||
|
force_update: document.getElementById('policyForceUpdate').checked,
|
||||||
|
allow_rollback: document.getElementById('policyAllowRollback').checked,
|
||||||
|
offline_allowed: document.getElementById('policyOfflineAllowed').checked,
|
||||||
|
min_supported_version: document.getElementById('policyMinVersion').value.trim(),
|
||||||
|
valid_until: document.getElementById('policyValidUntil').value.trim(),
|
||||||
|
disabled_versions: disabled, message: document.getElementById('policyMessage').value.trim()
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = await request('/admin/policy/save', {method:'POST', contentType:'application/json', body:JSON.stringify(body)});
|
||||||
|
log(result); notify('策略已保存,新序列为 ' + result.policy_seq); await loadPolicy();
|
||||||
|
} catch (err) { log(err); notify('保存策略失败', true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReports() {
|
||||||
|
log('正在加载升级日志...');
|
||||||
|
try {
|
||||||
|
const data = await request('/admin/report/list');
|
||||||
|
const rows = data.list || [];
|
||||||
|
reportRows.innerHTML = rows.length ? '' : '<tr><td colspan="5" class="empty">暂无升级日志</td></tr>';
|
||||||
|
rows.forEach(item => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${item.device_id}</td>
|
||||||
|
<td>${item.from_version}</td>
|
||||||
|
<td>${item.to_version}</td>
|
||||||
|
<td><span class="badge ${item.result === 'success' ? 'success' : 'fail'}">${item.result === 'success' ? '成功' : '失败'}</span></td>
|
||||||
|
<td>${item.create_time || ''}</td>
|
||||||
|
`;
|
||||||
|
reportRows.appendChild(tr);
|
||||||
|
});
|
||||||
|
log({ reports: rows });
|
||||||
|
} catch (err) {
|
||||||
|
log({ error: '加载日志失败', err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adminTokenInput.addEventListener('change', () => {
|
||||||
|
currentToken = adminTokenInput.value.trim() || currentToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleTokenBtn.addEventListener('click', () => {
|
||||||
|
const showing = adminTokenInput.type === 'text';
|
||||||
|
adminTokenInput.type = showing ? 'password' : 'text';
|
||||||
|
toggleTokenBtn.textContent = showing ? '显示' : '隐藏';
|
||||||
|
toggleTokenBtn.setAttribute('aria-label', showing ? '显示令牌' : '隐藏令牌');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
|
||||||
|
const token = adminTokenInput.value.trim();
|
||||||
|
if (!token) { notify('令牌不能为空', true); return; }
|
||||||
|
currentToken = token;
|
||||||
|
localStorage.setItem('admin_token', currentToken);
|
||||||
|
try {
|
||||||
|
await loadApps();
|
||||||
|
notify('令牌验证成功并已保存');
|
||||||
|
} catch (err) {
|
||||||
|
notify('令牌验证失败', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('changeTokenBtn').addEventListener('click', () => {
|
||||||
|
tokenChangePanel.hidden = false;
|
||||||
|
newAdminTokenInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cancelChangeTokenBtn').addEventListener('click', () => {
|
||||||
|
tokenChangePanel.hidden = true;
|
||||||
|
newAdminTokenInput.value = '';
|
||||||
|
confirmAdminTokenInput.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
|
||||||
|
const nextToken = newAdminTokenInput.value.trim();
|
||||||
|
const confirmation = confirmAdminTokenInput.value.trim();
|
||||||
|
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
|
||||||
|
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
|
||||||
|
try {
|
||||||
|
const result = await request('/admin/token/change', { method: 'POST', contentType: 'application/json', body: JSON.stringify({ new_token: nextToken }) });
|
||||||
|
currentToken = nextToken;
|
||||||
|
adminTokenInput.value = nextToken;
|
||||||
|
localStorage.setItem('admin_token', nextToken);
|
||||||
|
tokenChangePanel.hidden = true;
|
||||||
|
newAdminTokenInput.value = '';
|
||||||
|
confirmAdminTokenInput.value = '';
|
||||||
|
log(result);
|
||||||
|
notify('管理员令牌已更改');
|
||||||
|
} catch (err) {
|
||||||
|
log(err);
|
||||||
|
notify('更改令牌失败,请确认当前令牌正确', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('refreshAppsBtn').addEventListener('click', loadApps);
|
||||||
|
document.getElementById('addAppBtn').addEventListener('click', addApp);
|
||||||
|
publishBtn.addEventListener('click', publishVersion);
|
||||||
|
publishFilesInput.addEventListener('change', renderSelectedDirectory);
|
||||||
|
document.getElementById('refreshVersionsBtn').addEventListener('click', loadVersions);
|
||||||
|
document.getElementById('refreshReportsBtn').addEventListener('click', loadReports);
|
||||||
|
document.getElementById('loadPolicyBtn').addEventListener('click', loadPolicy);
|
||||||
|
document.getElementById('savePolicyBtn').addEventListener('click', savePolicy);
|
||||||
|
document.getElementById('policyChannel').addEventListener('change', loadPolicy);
|
||||||
|
|
||||||
|
appSelect.addEventListener('change', (event) => {
|
||||||
|
currentAppId = event.target.value;
|
||||||
|
if (currentAppId) {
|
||||||
|
publishAppIdInput.value = currentAppId;
|
||||||
|
}
|
||||||
|
currentAppLabel.textContent = currentAppId || '未选择';
|
||||||
|
if (currentAppId) loadPolicy();
|
||||||
|
});
|
||||||
|
|
||||||
|
versionRows.addEventListener('click', async (event) => {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
if (!button) return;
|
||||||
|
const action = button.dataset.action;
|
||||||
|
const id = button.dataset.id;
|
||||||
|
if (action === 'set-latest') {
|
||||||
|
await setLatest(id);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
await deleteVersion(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('load', async () => {
|
||||||
|
updateTokenInput();
|
||||||
|
await loadApps();
|
||||||
|
await loadReports();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Server]
|
||||||
|
api_base_url=http://192.168.229.128:8000
|
||||||
|
client_token=AuthKey202606Demo
|
||||||
|
|
||||||
|
[App]
|
||||||
|
app_id=testadmin
|
||||||
|
current_version=1.0.0
|
||||||
|
channel=stable
|
||||||
|
launch_token=UpdateSecret2026Demo
|
||||||
|
|
||||||
|
[Update]
|
||||||
|
request_timeout_ms=5000
|
||||||
|
temp_folder=update_temp
|
||||||
|
device_id=test_device_001
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"app_id": "your_app_id",
|
||||||
|
"app_name": "Your Application",
|
||||||
|
"channel": "stable",
|
||||||
|
"api_base_url": "http://127.0.0.1:8000",
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"current_version": "1.0.0",
|
||||||
|
"client_token": "replace_me",
|
||||||
|
"launch_token": "replace_me",
|
||||||
|
"request_timeout_ms": 5000,
|
||||||
|
"temp_folder": "update_temp",
|
||||||
|
"device_id": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"max_policy_seq": 0,
|
||||||
|
"last_success_run_at": "",
|
||||||
|
"last_online_verified_at": "",
|
||||||
|
"last_success_version": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
|
||||||
|
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
|
||||||
|
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
|
||||||
|
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
|
||||||
|
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
|
||||||
|
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
|
||||||
|
TwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"disabled_versions":[],"force_update":false,"offline_allowed":true,"policy_seq":1,"valid_until":"2099-12-31T23:59:59Z","signature":"d6982cb02d9a0c6fb6b7176961a7736dbee0decfbd5e422bd189521c72664205"}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+538
@@ -0,0 +1,538 @@
|
|||||||
|
软件自动升级与版本控制系统——项目进度
|
||||||
|
更新时间:2026-07-01
|
||||||
|
依据:《软件自动升级与版本控制系统开发设计文档 v0.1》及当前 server/client 源码
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
一、项目进度概述
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
目前项目已经完成一套可实际运行的 Windows 在线自动更新主链路。
|
||||||
|
|
||||||
|
客户端已经具备 Launcher、Updater、MainApp、Bootstrap 四个程序;服务端已经具备 FastAPI、SQLite、MinIO、版本发布、Manifest、下载授权、升级结果上报、版本策略管理和管理网页。客户端可以检查版本、验证 RSA 签名、差异下载、断点续传、备份旧文件、通过 Bootstrap 替换被占用文件、删除废弃文件、启动新版本并等待健康确认;失败时可以进入自动回滚流程。
|
||||||
|
|
||||||
|
目前 1.1.3 的正常升级流程已经实际运行成功,说明在线更新的成功路径已经基本打通。
|
||||||
|
|
||||||
|
按需求文档的 Demo 里程碑判断:
|
||||||
|
|
||||||
|
1. 第一阶段“基础框架”:基本完成。
|
||||||
|
2. 第二阶段“服务端基础能力”:基本完成。
|
||||||
|
3. 第三阶段“在线更新”:基本完成,正常升级已实测。
|
||||||
|
4. 第四阶段“版本策略”:大部分完成。
|
||||||
|
5. 第五阶段“离线能力”:完成离线运行基础,离线更新包尚未实现。
|
||||||
|
6. 第六阶段“可靠性与安全”:完成事务、回滚、策略防回退和时间检测;插件白名单、限流、完整日志等尚未实现。
|
||||||
|
|
||||||
|
如果只计算“在线更新 Demo”,当前完成度约为 80%。
|
||||||
|
如果按照整份设计文档计算,当前完成度约为 60%~65%。
|
||||||
|
|
||||||
|
当前最主要的剩余工作不是普通在线更新,而是进一步补齐安全准入和管理能力,包括:一次性启动票据、MainApp 启动时核心文件完整性检查、设备身份、插件白名单、下载与审计日志、动态渠道、限流、离线更新包等。
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
二、已经实现的功能
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
2.1 客户端基础框架
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. Launcher.exe。
|
||||||
|
2. Updater.exe。
|
||||||
|
3. MainApp.exe。
|
||||||
|
4. 独立原生 Bootstrap.exe。
|
||||||
|
5. Qt 图形提示和更新进度界面。
|
||||||
|
6. MainApp 显示当前版本。
|
||||||
|
7. MainApp 显示 Demo DLL 的简化 Hash 标识。
|
||||||
|
8. Launcher 启动 MainApp。
|
||||||
|
9. 直接双击 MainApp 时拒绝运行。
|
||||||
|
10. Windows x64 CMake 工程。
|
||||||
|
11. Qt 5.15、MSVC、OpenSSL 构建配置。
|
||||||
|
|
||||||
|
说明:MainApp 当前读取 Demo DLL 内容并显示 Hash 标识,但还没有通过 QLibrary 真正加载并调用插件接口,因此“插件实际加载”只算部分完成。
|
||||||
|
|
||||||
|
2.2 本地 JSON 配置
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 使用 config/app_config.json 保存客户端配置。
|
||||||
|
2. 支持旧 client.ini 自动迁移。
|
||||||
|
3. 保存 API 地址、App ID、当前版本、渠道、设备 ID、客户端 Token、启动 Token、平台和架构。
|
||||||
|
4. 更新成功后写入 current_version。
|
||||||
|
5. 使用 QSaveFile 原子写入主要配置。
|
||||||
|
6. 运行时配置不进入发布包。
|
||||||
|
7. 已配置项目 .gitignore。
|
||||||
|
|
||||||
|
2.3 服务端基础能力
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. FastAPI HTTP 服务。
|
||||||
|
2. SQLite 数据库。
|
||||||
|
3. MinIO 对象存储。
|
||||||
|
4. 应用创建与查询。
|
||||||
|
5. 版本发布、查询、设置最新和删除。
|
||||||
|
6. stable、preview、dev 三个固定渠道。
|
||||||
|
7. 删除版本时同步删除 MinIO 文件。
|
||||||
|
8. 发布失败时清理 MinIO 和本地回退目录中的半成品。
|
||||||
|
9. 大文件上传前检查磁盘空间。
|
||||||
|
10. multipart 临时文件存放到 /dev/shm,避免与 MinIO 双重占用根分区。
|
||||||
|
11. MinIO 不可用时支持本地存储回退。
|
||||||
|
12. 管理员 Token 鉴权和令牌修改。
|
||||||
|
13. 管理页面和跨域配置。
|
||||||
|
|
||||||
|
当前主要接口:
|
||||||
|
|
||||||
|
1. /api/v1/update/check
|
||||||
|
2. /api/v1/update/manifest
|
||||||
|
3. /api/v1/update/download-url
|
||||||
|
4. /api/v1/update/report
|
||||||
|
5. /admin/publish
|
||||||
|
6. /admin/version/list
|
||||||
|
7. /admin/version/set-latest
|
||||||
|
8. /admin/version/delete
|
||||||
|
9. /admin/policy
|
||||||
|
10. /admin/policy/save
|
||||||
|
11. /admin/report/list
|
||||||
|
|
||||||
|
2.4 软件根目录和多层目录发布
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 浏览器一次选择软件根目录。
|
||||||
|
2. 保留所有文件的相对路径。
|
||||||
|
3. 支持多层 DLL、插件和资源目录。
|
||||||
|
4. 服务端阻止绝对路径、..、盘符路径和重复路径。
|
||||||
|
5. Manifest、MinIO 和客户端安装过程都保留相对路径。
|
||||||
|
6. 检查 MainApp.exe 是否位于发布根级。
|
||||||
|
7. 排除 Bootstrap、运行时配置、状态文件、策略缓存、PDB、ILK 和更新临时目录。
|
||||||
|
|
||||||
|
2.5 Manifest
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 服务端动态生成全量 Manifest。
|
||||||
|
2. 包含 App ID、版本、渠道、平台、架构、Manifest 序列和创建时间。
|
||||||
|
3. 包含文件相对路径、大小、SHA-256 和 executable 标记。
|
||||||
|
4. 使用稳定 JSON 序列化。
|
||||||
|
5. 使用 RSA-2048/SHA-256 签名。
|
||||||
|
6. 客户端使用内置公钥验签。
|
||||||
|
7. 签名失败时拒绝安装。
|
||||||
|
8. Manifest 本地缓存。
|
||||||
|
9. 新旧 Manifest 对比。
|
||||||
|
10. 根据新旧 Manifest 差集删除废弃文件。
|
||||||
|
|
||||||
|
2.6 在线升级完整流程
|
||||||
|
|
||||||
|
已实现流程:
|
||||||
|
|
||||||
|
1. Launcher 请求更新检查。
|
||||||
|
2. 服务端返回目标版本和签名策略。
|
||||||
|
3. Launcher 验证版本策略 RSA 签名。
|
||||||
|
4. Launcher 根据策略决定升级、降级、继续运行或禁止运行。
|
||||||
|
5. 启动 Updater。
|
||||||
|
6. Updater 获取并验证 Manifest。
|
||||||
|
7. 获取 MinIO 预签名下载地址。
|
||||||
|
8. 比较本地文件 SHA-256。
|
||||||
|
9. 只下载新增或变化的文件。
|
||||||
|
10. 校验文件大小和 SHA-256。
|
||||||
|
11. 备份旧文件。
|
||||||
|
12. Updater 退出并移交 Bootstrap。
|
||||||
|
13. Bootstrap 替换文件或删除废弃文件。
|
||||||
|
14. Bootstrap 重新启动 Updater 续办事务。
|
||||||
|
15. Updater 完整校验安装结果。
|
||||||
|
16. 保存新版本号。
|
||||||
|
17. 启动 MainApp 并等待健康确认。
|
||||||
|
18. 健康确认成功后提交事务。
|
||||||
|
19. 上报升级成功结果。
|
||||||
|
|
||||||
|
2.7 差异下载、断点续传和下载体验
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. SHA 相同的文件跳过下载。
|
||||||
|
2. HTTP Range 断点续传。
|
||||||
|
3. 使用 update/download_cache/<sha256>.part 保存片段。
|
||||||
|
4. Updater 重启后仍可继续未完成文件。
|
||||||
|
5. 单文件最多自动重试四次。
|
||||||
|
6. 使用递增等待时间重试。
|
||||||
|
7. 服务端不支持 Range 时安全地完整重下。
|
||||||
|
8. 下载后验证大小和 SHA-256。
|
||||||
|
9. 显示当前文件、已下载量、总下载量、速度和总进度。
|
||||||
|
10. 清理不属于当前 Manifest 的旧片段。
|
||||||
|
|
||||||
|
2.8 客户端磁盘空间预检
|
||||||
|
|
||||||
|
下载前会计算:
|
||||||
|
|
||||||
|
1. 尚未下载的字节数。
|
||||||
|
2. 已存在的断点片段大小。
|
||||||
|
3. 被覆盖文件需要的备份空间。
|
||||||
|
4. 废弃文件需要的备份空间。
|
||||||
|
5. 至少 128MB 的安全余量。
|
||||||
|
|
||||||
|
空间不足时会在开始下载前阻止更新,并显示所需空间和当前可用空间。
|
||||||
|
|
||||||
|
2.9 升级事务状态机
|
||||||
|
|
||||||
|
已实现 update/upgrade_state.json,包含:
|
||||||
|
|
||||||
|
1. transaction_id。
|
||||||
|
2. from_version。
|
||||||
|
3. to_version。
|
||||||
|
4. status。
|
||||||
|
5. manifest_id。
|
||||||
|
6. staging_dir。
|
||||||
|
7. backup_dir。
|
||||||
|
8. changed_paths。
|
||||||
|
9. obsolete_paths。
|
||||||
|
10. error_code 和 message。
|
||||||
|
|
||||||
|
已经实现的主要状态:
|
||||||
|
|
||||||
|
prepared、verified、waiting_mainapp_exit、backed_up、awaiting_bootstrap、replacing、replaced、post_verify、rollback_required、rolling_back、rolled_back、committed、failed。
|
||||||
|
|
||||||
|
Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
|
||||||
|
|
||||||
|
2.10 Bootstrap 自更新机制
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. Bootstrap 不依赖 Qt。
|
||||||
|
2. Updater 退出后由 Bootstrap 接管安装。
|
||||||
|
3. 可以替换 Updater.exe、Launcher.exe、MainApp.exe、Qt DLL、OpenSSL DLL、Qt 插件和业务文件。
|
||||||
|
4. 完成后重新启动 Updater 续办事务。
|
||||||
|
5. 回滚也由 Bootstrap 执行,避免运行中的 Updater 锁住自己。
|
||||||
|
6. Bootstrap 自身属于不可由普通更新事务替换的根组件。
|
||||||
|
7. 旧 Manifest 即使包含 Bootstrap,也会由客户端作为受保护文件忽略。
|
||||||
|
|
||||||
|
2.11 自动回滚和健康检查
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 安装前备份旧文件。
|
||||||
|
2. 替换失败时回滚。
|
||||||
|
3. 安装后 Hash 校验失败时回滚。
|
||||||
|
4. MainApp 无法启动时回滚。
|
||||||
|
5. MainApp 15 秒内未写入健康标记时回滚。
|
||||||
|
6. 版本状态保存失败时回滚。
|
||||||
|
7. 回滚后恢复旧版本号。
|
||||||
|
8. 回滚后重新启动旧 MainApp。
|
||||||
|
9. 被删除的废弃文件也会在回滚时恢复。
|
||||||
|
10. Updater 自身发生变化时由 Bootstrap 执行回滚。
|
||||||
|
|
||||||
|
2.12 版本运行策略
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. version_policies 数据表。
|
||||||
|
2. 每个应用和渠道分别保存策略。
|
||||||
|
3. 每次修改自动递增 policy_seq。
|
||||||
|
4. RSA 签名策略在线下发。
|
||||||
|
5. Launcher 验证策略签名。
|
||||||
|
6. 签名失败时拒绝使用。
|
||||||
|
7. 策略原子缓存到本地。
|
||||||
|
8. policy_seq 防回滚。
|
||||||
|
9. 强制升级。
|
||||||
|
10. 禁用指定版本。
|
||||||
|
11. 最低支持版本。
|
||||||
|
12. 允许或禁止降级。
|
||||||
|
13. 允许或禁止离线启动。
|
||||||
|
14. 策略有效期。
|
||||||
|
15. 自定义客户端提示。
|
||||||
|
16. 管理页面策略编辑区。
|
||||||
|
17. 上次在线验证时间和系统时间回拨检测。
|
||||||
|
|
||||||
|
2.13 受控降级和用户选择
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 管理员可以将历史版本设置为渠道最新。
|
||||||
|
2. 允许降级时服务端返回 rollback_allowed。
|
||||||
|
3. 禁止降级时返回 rollback_denied。
|
||||||
|
4. 用户可以选择是否执行降级。
|
||||||
|
5. 用户拒绝降级后继续运行当前版本。
|
||||||
|
6. 普通可选升级也允许用户选择稍后更新。
|
||||||
|
7. 强制升级不能跳过。
|
||||||
|
8. 降级复用完整事务、Bootstrap、校验、健康确认和失败回滚机制。
|
||||||
|
|
||||||
|
2.14 离线运行基础
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 在线策略本地缓存。
|
||||||
|
2. 本地策略 RSA 验签。
|
||||||
|
3. offline_allowed。
|
||||||
|
4. valid_until。
|
||||||
|
5. 策略过期时拒绝启动。
|
||||||
|
6. policy_seq 防回滚。
|
||||||
|
7. 记录上次成功启动时间。
|
||||||
|
8. 记录上次在线验证时间。
|
||||||
|
9. 检测系统时间是否回拨。
|
||||||
|
|
||||||
|
说明:目前实现的是“离线运行”,不是“离线升级”。
|
||||||
|
|
||||||
|
2.15 管理页面
|
||||||
|
|
||||||
|
已实现:
|
||||||
|
|
||||||
|
1. 管理员令牌输入、隐藏、显示和保存。
|
||||||
|
2. 修改管理员令牌。
|
||||||
|
3. 创建和选择应用。
|
||||||
|
4. 选择软件根目录发布。
|
||||||
|
5. stable、preview、dev 渠道选择。
|
||||||
|
6. 版本列表。
|
||||||
|
7. 设置最新版本。
|
||||||
|
8. 删除版本和云端文件。
|
||||||
|
9. 版本策略读取和保存。
|
||||||
|
10. 升级日志。
|
||||||
|
11. 调试输出。
|
||||||
|
12. 发布文件预览、大小和状态提示。
|
||||||
|
13. 页面美化和响应式布局。
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
三、部分实现的功能
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
3.1 启动票据
|
||||||
|
|
||||||
|
当前实现:Launcher 或 Updater 通过 --launcher-token 参数启动 MainApp,直接双击 MainApp 会被拒绝。
|
||||||
|
|
||||||
|
与需求差距:
|
||||||
|
|
||||||
|
1. 不是临时 ticket 文件。
|
||||||
|
2. 没有 app_id、client_id、device_id、version、nonce、issued_at、expires_at。
|
||||||
|
3. 没有一次性使用后删除。
|
||||||
|
4. 没有防重放。
|
||||||
|
5. 使用的是配置中的长期固定 Token。
|
||||||
|
|
||||||
|
结论:只能防止普通用户直接双击,不能算完整安全启动票据。
|
||||||
|
|
||||||
|
3.2 MainApp 启动完整性检查
|
||||||
|
|
||||||
|
当前 MainApp 会检查启动 Token、版本策略签名、策略有效期、policy_seq 和时间回拨。
|
||||||
|
|
||||||
|
尚缺少:
|
||||||
|
|
||||||
|
1. 启动时重新验证当前 Manifest。
|
||||||
|
2. 核心 EXE/DLL 全量 Hash。
|
||||||
|
3. 检测受控目录中未被 Manifest 声明的额外 EXE/DLL。
|
||||||
|
4. 插件白名单。
|
||||||
|
5. MainApp 自身 Hash 验证。
|
||||||
|
|
||||||
|
因此需求中的“手动篡改 DLL 后启动失败”目前不能保证通过。
|
||||||
|
|
||||||
|
3.3 回滚完整性
|
||||||
|
|
||||||
|
文件恢复和版本号恢复已经实现,但仍缺少:
|
||||||
|
|
||||||
|
1. 回滚完成后加载旧 Manifest 并进行完整 Hash 校验。
|
||||||
|
2. 更详细的逐文件回滚错误。
|
||||||
|
3. 回滚失败后的修复安装入口。
|
||||||
|
4. 完整断电、杀进程、文件占用测试。
|
||||||
|
|
||||||
|
3.4 Manifest 安全字段
|
||||||
|
|
||||||
|
RSA 签名已经实现,但仍缺少:
|
||||||
|
|
||||||
|
1. signature_alg 字段。
|
||||||
|
2. key_id 字段。
|
||||||
|
3. 当前和上一公钥同时内置。
|
||||||
|
4. 公钥轮换流程。
|
||||||
|
5. 密钥吊销机制。
|
||||||
|
|
||||||
|
3.5 升级日志
|
||||||
|
|
||||||
|
当前只记录 device_id、旧版本、新版本、success/fail 和时间。
|
||||||
|
|
||||||
|
尚缺少:
|
||||||
|
|
||||||
|
1. app_id 和 channel。
|
||||||
|
2. transaction_id。
|
||||||
|
3. error_code。
|
||||||
|
4. 失败阶段和失败文件。
|
||||||
|
5. 下载字节数和耗时。
|
||||||
|
6. 回滚结果。
|
||||||
|
7. 操作系统、架构和客户端 IP。
|
||||||
|
|
||||||
|
3.6 REST API 契约
|
||||||
|
|
||||||
|
当前更新检查主要接收 app_id、current_version 和 channel。
|
||||||
|
|
||||||
|
需求文档中的以下字段尚未进入完整闭环:
|
||||||
|
|
||||||
|
1. client_id。
|
||||||
|
2. device_id。
|
||||||
|
3. license_id。
|
||||||
|
4. platform。
|
||||||
|
5. arch。
|
||||||
|
6. operation。
|
||||||
|
7. target_version。
|
||||||
|
|
||||||
|
3.7 管理员登录
|
||||||
|
|
||||||
|
当前采用单管理员 Token,服务端只保存 Token Hash。
|
||||||
|
|
||||||
|
尚缺少:
|
||||||
|
|
||||||
|
1. admin_users 表。
|
||||||
|
2. 正式登录接口。
|
||||||
|
3. Token 过期时间。
|
||||||
|
4. 多管理员和角色权限。
|
||||||
|
5. 登录失败审计。
|
||||||
|
6. 管理员操作审计。
|
||||||
|
|
||||||
|
3.8 数据库结构
|
||||||
|
|
||||||
|
已有 apps、versions、version_files、version_policies、upgrade_logs、update_report。
|
||||||
|
|
||||||
|
部分字段仍未达到文档设计,例如:
|
||||||
|
|
||||||
|
1. versions 缺少 status、allow_rollback、rollback_targets、描述和发布时间等字段。
|
||||||
|
2. version_files 缺少 storage_key、file_type、is_required。
|
||||||
|
3. apps 字段较少。
|
||||||
|
4. 缺少较完整的外键、索引和约束。
|
||||||
|
|
||||||
|
3.9 精确降级控制
|
||||||
|
|
||||||
|
已有允许/禁止降级,但仍缺少:
|
||||||
|
|
||||||
|
1. rollback_targets 目标白名单。
|
||||||
|
2. 数据格式兼容性规则。
|
||||||
|
3. 主动输入目标版本。
|
||||||
|
4. 不同版本间的允许降级关系。
|
||||||
|
5. “允许用户降级”和“管理员强制回退”的独立策略。
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
四、尚未实现的核心功能
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
4.1 一次性短期启动票据
|
||||||
|
|
||||||
|
需要实现临时 ticket 文件、有效期、nonce、签名、版本绑定、设备绑定、一次使用后删除和防重放。
|
||||||
|
|
||||||
|
4.2 MainApp 核心文件完整性准入
|
||||||
|
|
||||||
|
需要在每次启动时验证当前已安装 Manifest、MainApp、核心 DLL、插件目录和额外可执行文件。
|
||||||
|
|
||||||
|
4.3 设备身份和激活
|
||||||
|
|
||||||
|
尚未实现:
|
||||||
|
|
||||||
|
1. client_identity.dat 正式签发。
|
||||||
|
2. 设备身份 RSA 签名。
|
||||||
|
3. /api/v1/device/issue。
|
||||||
|
4. 首次设备激活和刷新。
|
||||||
|
5. 设备禁用。
|
||||||
|
6. devices 表。
|
||||||
|
7. 每次请求验证设备身份。
|
||||||
|
|
||||||
|
当前 device_id 只是普通配置字符串,客户端请求使用共享 client_token。
|
||||||
|
|
||||||
|
4.4 License 授权系统
|
||||||
|
|
||||||
|
尚未实现 licenses 表、授权有效期、设备绑定、最大设备数、授权禁用、许可证签名和授权渠道控制。
|
||||||
|
|
||||||
|
4.5 动态渠道管理
|
||||||
|
|
||||||
|
目前 stable、preview、dev 写死在客户端和后台。
|
||||||
|
|
||||||
|
尚未实现 channels 表、渠道新增、启用、禁用、渠道名称和按应用配置渠道。
|
||||||
|
|
||||||
|
4.6 离线更新包
|
||||||
|
|
||||||
|
尚未实现:
|
||||||
|
|
||||||
|
1. .upd 离线包。
|
||||||
|
2. 离线包生成。
|
||||||
|
3. 离线包 Manifest 和 RSA 签名。
|
||||||
|
4. 本地导入和校验。
|
||||||
|
5. 解包到 staging。
|
||||||
|
6. 离线事务安装。
|
||||||
|
|
||||||
|
4.7 插件白名单
|
||||||
|
|
||||||
|
尚未实现插件目录扫描、未知 DLL 检测、插件签名/Hash 准入和插件接口版本检查。
|
||||||
|
|
||||||
|
4.8 服务端限流
|
||||||
|
|
||||||
|
尚未实现更新检查限流、下载链接限流、完整包下载次数限制、每日字节额度、管理 API 限流以及 HTTP 429/RATE_LIMITED。
|
||||||
|
|
||||||
|
4.9 下载日志
|
||||||
|
|
||||||
|
尚未实现 download_logs 表,以及设备、版本、文件、下载字节、结果、IP 和时间等业务下载记录。
|
||||||
|
|
||||||
|
4.10 管理员审计日志
|
||||||
|
|
||||||
|
尚未实现 admin_audit_logs,包括发布、删除、设置最新、修改策略、修改令牌、登录失败等操作记录。
|
||||||
|
|
||||||
|
4.11 代码签名
|
||||||
|
|
||||||
|
尚未实现 Windows Authenticode、发布者验证和 EXE/DLL 代码签名检查。
|
||||||
|
|
||||||
|
4.12 灰度发布
|
||||||
|
|
||||||
|
尚未实现按设备、客户、地区、百分比或批次灰度,以及失败率自动停止。
|
||||||
|
|
||||||
|
4.13 多平台
|
||||||
|
|
||||||
|
目前只支持 Windows x64,尚未支持 Windows ARM64、Linux、macOS 和多平台 Manifest 分流。
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
五、需求文档 Demo 里程碑状态
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
第一阶段:基础框架——基本完成。
|
||||||
|
缺口:Demo 插件尚未真正通过接口加载。
|
||||||
|
|
||||||
|
第二阶段:服务端基础能力——基本完成。
|
||||||
|
缺口:动态 channels 表和设备身份未完成。
|
||||||
|
|
||||||
|
第三阶段:在线更新——基本完成。
|
||||||
|
正常升级 1.1.3 已实际测试成功。
|
||||||
|
|
||||||
|
第四阶段:版本策略——大部分完成。
|
||||||
|
缺口:动态渠道、rollback_targets 和强制回退独立策略。
|
||||||
|
|
||||||
|
第五阶段:离线能力——部分完成。
|
||||||
|
已经支持离线运行和策略有效期;尚未实现离线更新包。
|
||||||
|
|
||||||
|
第六阶段:可靠性与安全——部分完成。
|
||||||
|
已经支持事务回滚、policy_seq 和时间回拨检测;尚未实现插件白名单、限流、完整下载日志和启动完整性准入。
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
六、需求文档验收用例状态
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
1. 通过 Launcher 正常启动 MainApp:已实现。
|
||||||
|
2. 直接启动 MainApp 被拒绝:已实现,但启动票据较弱。
|
||||||
|
3. MainApp 显示主程序和 DLL 版本:已实现简化版。
|
||||||
|
4. 后台发布版本:已实现。
|
||||||
|
5. 客户端在线升级:已实现并实测。
|
||||||
|
6. 升级后 DLL 变化:支持。
|
||||||
|
7. 升级后 MainApp 自动重启:已实现。
|
||||||
|
8. 手动篡改 DLL 后启动失败:未完整实现。
|
||||||
|
9. 手动篡改 version_policy 后启动失败:已实现 RSA 验签。
|
||||||
|
10. 强制升级:已实现,待最终客户端测试。
|
||||||
|
11. 禁用版本:已实现,待最终客户端测试。
|
||||||
|
12. preview 客户端获取 preview 更新:基础支持,待测试。
|
||||||
|
13. 禁止降级:已实现并进行过 API 测试。
|
||||||
|
14. 离线凭证未过期时启动:已实现,待测试。
|
||||||
|
15. 离线凭证过期后拒绝启动:已实现,待测试。
|
||||||
|
16. 模拟升级失败后成功回滚:代码已实现,待完整故障测试。
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
七、推荐后续开发顺序
|
||||||
|
============================================================
|
||||||
|
|
||||||
|
建议依次实现:
|
||||||
|
|
||||||
|
1. 一次性、短期、防重放的启动票据。
|
||||||
|
2. MainApp 启动时核心文件完整性检查和插件白名单。
|
||||||
|
3. 设备身份签发与服务端验证。
|
||||||
|
4. 升级日志错误码、失败阶段、回滚结果和下载日志。
|
||||||
|
5. 管理员审计日志。
|
||||||
|
6. 动态渠道管理。
|
||||||
|
7. rollback_targets 精确降级目标。
|
||||||
|
8. 服务端限流。
|
||||||
|
9. 离线更新包。
|
||||||
|
10. 最后统一进行成功升级、强制升级、版本禁用、离线、断点续传、文件占用、进程中断、回滚和策略篡改测试。
|
||||||
|
|
||||||
|
当前下一项最值得实现的是:一次性启动票据,以及 MainApp 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DB_FILE = os.getenv("DB_FILE")
|
||||||
|
SQL_FILE = os.getenv("SQL_FILE")
|
||||||
|
|
||||||
|
# 初始化数据库
|
||||||
|
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()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
|
||||||
|
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
|
||||||
|
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
|
||||||
|
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
|
||||||
|
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
|
||||||
|
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
|
||||||
|
TwIDAQAB
|
||||||
|
-----END PUBLIC KEY-----
|
||||||
+780
@@ -0,0 +1,780 @@
|
|||||||
|
from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
import db
|
||||||
|
import minio_tool
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
|
||||||
|
# 加载环境变量
|
||||||
|
load_dotenv()
|
||||||
|
SERVER_HOST = os.getenv("SERVER_HOST")
|
||||||
|
SERVER_PORT = int(os.getenv("SERVER_PORT"))
|
||||||
|
VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN")
|
||||||
|
|
||||||
|
# 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。
|
||||||
|
UPLOAD_SPOOL_DIR = Path(os.getenv("UPLOAD_SPOOL_DIR", "/dev/shm/marsco-upload"))
|
||||||
|
UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tempfile.tempdir = str(UPLOAD_SPOOL_DIR)
|
||||||
|
MINIO_DATA_DIR = Path(os.getenv("MINIO_DATA_DIR", "minio_data"))
|
||||||
|
UPLOAD_SPACE_RESERVE = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
# 后台管理令牌:仅持久化 SHA-256 哈希,不保存明文
|
||||||
|
ADMIN_TOKEN_HASH_PATH = Path(os.getenv("ADMIN_TOKEN_HASH_PATH", "admin_token.sha256"))
|
||||||
|
DEFAULT_ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "AdminSecret2026")
|
||||||
|
|
||||||
|
def token_digest(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
def load_admin_token_hash() -> str:
|
||||||
|
if ADMIN_TOKEN_HASH_PATH.exists():
|
||||||
|
stored = ADMIN_TOKEN_HASH_PATH.read_text(encoding="utf-8").strip()
|
||||||
|
if len(stored) == 64:
|
||||||
|
return stored
|
||||||
|
return token_digest(DEFAULT_ADMIN_TOKEN)
|
||||||
|
|
||||||
|
ADMIN_TOKEN_HASH = load_admin_token_hash()
|
||||||
|
|
||||||
|
print("读取到的CLIENT_API_TOKEN:", repr(VALID_CLIENT_TOKEN))
|
||||||
|
|
||||||
|
# ========== 生命周期初始化 ==========
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
print("===== 进程启动,开始初始化数据库 =====")
|
||||||
|
db_file = "mini.db"
|
||||||
|
sql_file_path = Path("./tables.sql")
|
||||||
|
conn = sqlite3.connect(db_file, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
if sql_file_path.exists():
|
||||||
|
with open(sql_file_path, "r", encoding="utf-8") as f:
|
||||||
|
full_sql = f.read()
|
||||||
|
cur.executescript(full_sql)
|
||||||
|
conn.commit()
|
||||||
|
print(f"✅ 成功执行tables.sql,创建数据表与测试数据")
|
||||||
|
else:
|
||||||
|
print(f"❌ 错误:当前目录找不到 tables.sql 文件!")
|
||||||
|
conn.close()
|
||||||
|
print("===== 数据库初始化完成 =====")
|
||||||
|
|
||||||
|
start_minio_if_needed()
|
||||||
|
|
||||||
|
yield
|
||||||
|
print("服务进程正常退出")
|
||||||
|
|
||||||
|
def is_minio_running(endpoint: str) -> bool:
|
||||||
|
try:
|
||||||
|
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
||||||
|
base_url = endpoint.rstrip("/")
|
||||||
|
else:
|
||||||
|
base_url = f"http://{endpoint.rstrip('/')}"
|
||||||
|
health_url = base_url + "/minio/health/live"
|
||||||
|
with urllib.request.urlopen(health_url, timeout=2) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def download_minio_binary(target_path: Path) -> str | None:
|
||||||
|
url = "https://dl.min.io/server/minio/release/linux-amd64/minio"
|
||||||
|
try:
|
||||||
|
print(f"MinIO 二进制未找到,尝试下载到 {target_path}")
|
||||||
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as response, open(target_path, 'wb') as out_file:
|
||||||
|
out_file.write(response.read())
|
||||||
|
target_path.chmod(0o755)
|
||||||
|
abs_path = str(target_path.resolve())
|
||||||
|
print(f"已下载 MinIO 二进制到 {abs_path}")
|
||||||
|
return abs_path
|
||||||
|
except Exception as err:
|
||||||
|
print(f"自动下载 MinIO 失败:{err}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def start_minio_if_needed():
|
||||||
|
endpoint = os.getenv('MINIO_ENDPOINT')
|
||||||
|
if not endpoint:
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_minio_running(endpoint):
|
||||||
|
print(f"MinIO 已在 {endpoint} 运行,跳过启动")
|
||||||
|
return
|
||||||
|
|
||||||
|
minio_cmd = shutil.which('minio')
|
||||||
|
if not minio_cmd:
|
||||||
|
minio_bin = Path(os.getenv('MINIO_BIN_PATH', 'minio'))
|
||||||
|
minio_cmd = download_minio_binary(minio_bin)
|
||||||
|
if not minio_cmd:
|
||||||
|
print("MinIO 二进制未找到且自动下载失败,无法启动 MinIO")
|
||||||
|
return
|
||||||
|
|
||||||
|
data_dir = Path(os.getenv('MINIO_DATA_DIR', 'minio_data'))
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address ':9000'")
|
||||||
|
try:
|
||||||
|
subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', ':9000'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
time.sleep(5)
|
||||||
|
if is_minio_running(endpoint):
|
||||||
|
print("MinIO 已启动成功")
|
||||||
|
else:
|
||||||
|
print("MinIO 启动失败,仍然无法连接")
|
||||||
|
except Exception as err:
|
||||||
|
print(f"启动 MinIO 失败:{err}")
|
||||||
|
|
||||||
|
|
||||||
|
# Manifest signing key path
|
||||||
|
MANIFEST_PRIVATE_KEY_PATH = os.getenv("MANIFEST_PRIVATE_KEY_PATH", str(Path(__file__).resolve().parent / "keys" / "manifest_private_key.pem"))
|
||||||
|
|
||||||
|
# 创建APP绑定生命周期
|
||||||
|
app = FastAPI(title="最小自动更新服务", lifespan=lifespan)
|
||||||
|
|
||||||
|
# 本地上传备份文件目录(MinIO 不通时会保存到这里)
|
||||||
|
app.mount("/static", StaticFiles(directory="local_uploads"), name="static")
|
||||||
|
|
||||||
|
# 跨域配置(给admin前端页面用)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===================== 全局客户端鉴权中间件 =====================
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth_middleware(request: Request, call_next):
|
||||||
|
path = request.url.path
|
||||||
|
# 后台接口、文档接口、静态回退链接全部跳过客户端token校验
|
||||||
|
skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static")
|
||||||
|
if path.startswith(skip_paths):
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
client_token = request.headers.get("X-Client-Token")
|
||||||
|
print("本次请求携带token:", repr(client_token))
|
||||||
|
if client_token != VALID_CLIENT_TOKEN:
|
||||||
|
raise HTTPException(status_code=401, detail="非法客户端,令牌校验失败")
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ========== 请求体模型 ==========
|
||||||
|
class CheckUpdateReq(BaseModel):
|
||||||
|
app_id: str
|
||||||
|
current_version: str
|
||||||
|
channel: str
|
||||||
|
|
||||||
|
class DownloadUrlReq(BaseModel):
|
||||||
|
app_id: str
|
||||||
|
channel: str
|
||||||
|
version: str
|
||||||
|
version_id: int
|
||||||
|
files: list[str]
|
||||||
|
|
||||||
|
class ManifestReq(BaseModel):
|
||||||
|
app_id: str
|
||||||
|
channel: str
|
||||||
|
version: str
|
||||||
|
version_id: int
|
||||||
|
|
||||||
|
class ReportReq(BaseModel):
|
||||||
|
device_id: str
|
||||||
|
from_version: str
|
||||||
|
to_version: str
|
||||||
|
result: str
|
||||||
|
|
||||||
|
class ChangeAdminTokenReq(BaseModel):
|
||||||
|
new_token: str
|
||||||
|
|
||||||
|
# 统一后台鉴权:全部接口从Header读取token,不再区分表单/头
|
||||||
|
def admin_auth(X_Admin_Token: str = Header("")):
|
||||||
|
if not secrets.compare_digest(token_digest(X_Admin_Token), ADMIN_TOKEN_HASH):
|
||||||
|
raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Manifest signing helpers
|
||||||
|
|
||||||
|
def load_manifest_private_key():
|
||||||
|
key_path = Path(MANIFEST_PRIVATE_KEY_PATH)
|
||||||
|
if not key_path.exists():
|
||||||
|
raise FileNotFoundError(f"Manifest private key not found: {key_path}")
|
||||||
|
key_data = key_path.read_bytes()
|
||||||
|
return load_pem_private_key(key_data, password=None)
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_manifest_bytes(manifest_obj: dict) -> bytes:
|
||||||
|
copy = {k: manifest_obj[k] for k in manifest_obj if k != "signature"}
|
||||||
|
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_manifest(manifest_obj: dict) -> str:
|
||||||
|
json_text = canonical_manifest_bytes(manifest_obj)
|
||||||
|
private_key = load_manifest_private_key()
|
||||||
|
signature = private_key.sign(
|
||||||
|
json_text,
|
||||||
|
padding.PKCS1v15(),
|
||||||
|
hashes.SHA256()
|
||||||
|
)
|
||||||
|
return base64.b64encode(signature).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_signed_bytes(obj: dict) -> bytes:
|
||||||
|
copy = {k: obj[k] for k in obj if k != "signature"}
|
||||||
|
return json.dumps(copy, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_policy(policy_obj: dict) -> tuple[str, str]:
|
||||||
|
payload = canonical_signed_bytes(policy_obj)
|
||||||
|
signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256())
|
||||||
|
return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def policy_row_to_dict(row) -> dict:
|
||||||
|
if not row:
|
||||||
|
return {
|
||||||
|
"policy_seq": 1, "force_update": False, "allow_rollback": False,
|
||||||
|
"offline_allowed": True, "valid_until": "2099-12-31T23:59:59Z",
|
||||||
|
"min_supported_version": "", "disabled_versions": [], "message": ""
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
disabled = json.loads(row["disabled_versions"] or "[]")
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
disabled = []
|
||||||
|
return {
|
||||||
|
"policy_seq": int(row["policy_seq"]), "force_update": bool(row["force_update"]),
|
||||||
|
"allow_rollback": bool(row["allow_rollback"]), "offline_allowed": bool(row["offline_allowed"]),
|
||||||
|
"valid_until": row["valid_until"], "min_supported_version": row["min_supported_version"] or "",
|
||||||
|
"disabled_versions": disabled if isinstance(disabled, list) else [], "message": row["message"] or ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_executable_path(path: str) -> bool:
|
||||||
|
return path.lower().endswith((".exe", ".dll"))
|
||||||
|
|
||||||
|
def version_key(version: str):
|
||||||
|
parts = []
|
||||||
|
for part in version.split("."):
|
||||||
|
digits = "".join(ch for ch in part if ch.isdigit())
|
||||||
|
parts.append(int(digits) if digits else 0)
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
def normalize_relative_path(raw_path: str) -> str:
|
||||||
|
if not isinstance(raw_path, str):
|
||||||
|
raise HTTPException(status_code=400, detail="文件相对路径无效")
|
||||||
|
normalized = raw_path.replace(chr(92), "/")
|
||||||
|
if normalized.startswith("/"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}")
|
||||||
|
normalized = normalized.strip("/")
|
||||||
|
if not normalized or chr(0) in normalized:
|
||||||
|
raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符")
|
||||||
|
path = PurePosixPath(normalized)
|
||||||
|
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
|
||||||
|
raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}")
|
||||||
|
if path.parts and ":" in path.parts[0]:
|
||||||
|
raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}")
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
# 文件sha256工具
|
||||||
|
def calc_sha256(data: bytes):
|
||||||
|
sha = hashlib.sha256()
|
||||||
|
sha.update(data)
|
||||||
|
return sha.hexdigest()
|
||||||
|
|
||||||
|
# ==================== 原有客户端接口 完整保留 ====================
|
||||||
|
# 1. 查询更新接口
|
||||||
|
@app.post("/api/v1/update/check")
|
||||||
|
async def check_update(body: CheckUpdateReq = Body(...)):
|
||||||
|
print("收到客户端版本检测请求,参数:", body.model_dump())
|
||||||
|
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
|
||||||
|
conn = db.get_conn()
|
||||||
|
ver = conn.execute(
|
||||||
|
"SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1", (app_id, channel)
|
||||||
|
).fetchone()
|
||||||
|
policy_row = conn.execute(
|
||||||
|
"SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
settings = policy_row_to_dict(policy_row)
|
||||||
|
latest_ver = ver["version"] if ver else ""
|
||||||
|
version_id = int(ver["id"]) if ver else 0
|
||||||
|
upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver)
|
||||||
|
rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver)
|
||||||
|
rollback_allowed = rollback_candidate and bool(settings["allow_rollback"])
|
||||||
|
need_update = upgrade_available or rollback_allowed
|
||||||
|
disabled = cur_ver in settings["disabled_versions"]
|
||||||
|
below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"])
|
||||||
|
allow_run = not disabled and not below_min
|
||||||
|
force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min
|
||||||
|
if disabled:
|
||||||
|
action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用"
|
||||||
|
elif below_min:
|
||||||
|
action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}"
|
||||||
|
elif force_update:
|
||||||
|
action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用"
|
||||||
|
elif rollback_allowed:
|
||||||
|
action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}"
|
||||||
|
elif rollback_candidate:
|
||||||
|
action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级"
|
||||||
|
elif upgrade_available:
|
||||||
|
action, message = "optional_update", settings["message"] or "发现可用新版本"
|
||||||
|
else:
|
||||||
|
action, message = "allow", settings["message"] or "当前版本允许使用"
|
||||||
|
|
||||||
|
issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||||
|
policy = {
|
||||||
|
"app_id": app_id, "channel": channel, "current_version": cur_ver,
|
||||||
|
"policy_seq": settings["policy_seq"], "allow_run": allow_run,
|
||||||
|
"force_update": force_update, "allow_rollback": settings["allow_rollback"],
|
||||||
|
"rollback_targets": [], "offline_allowed": settings["offline_allowed"],
|
||||||
|
"issued_at": issued_at, "valid_until": settings["valid_until"],
|
||||||
|
"latest_version": latest_ver, "min_supported_version": settings["min_supported_version"],
|
||||||
|
"disabled_versions": settings["disabled_versions"], "message": message,
|
||||||
|
"signature_alg": "RSA-2048-SHA256", "key_id": "manifest-key-v1"
|
||||||
|
}
|
||||||
|
policy_text, policy_signature = sign_policy(policy)
|
||||||
|
policy["signature"] = policy_signature
|
||||||
|
return {
|
||||||
|
"allow_run": allow_run, "action": action, "message": message,
|
||||||
|
"need_update": need_update, "release_available": bool(ver),
|
||||||
|
"current_version": cur_ver, "latest_version": latest_ver, "version_id": version_id,
|
||||||
|
"force_update": force_update, "allow_rollback": settings["allow_rollback"],
|
||||||
|
"rollback": rollback_allowed,
|
||||||
|
"policy_seq": settings["policy_seq"], "policy_valid_until": settings["valid_until"],
|
||||||
|
"policy": policy, "policy_text": policy_text, "policy_signature": policy_signature
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. 获取文件下载链接
|
||||||
|
@app.post("/api/v1/update/download-url")
|
||||||
|
async def get_download_url(body: DownloadUrlReq = Body(...)):
|
||||||
|
ver_id = body.version_id
|
||||||
|
conn = db.get_conn()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT path, sha256, size FROM version_files WHERE version_id=?",
|
||||||
|
(ver_id,)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
res = []
|
||||||
|
base_path = f"{body.app_id}/{body.channel}/{body.version}/files/"
|
||||||
|
for r in rows:
|
||||||
|
full_path = base_path + r["path"]
|
||||||
|
url = minio_tool.get_url(full_path)
|
||||||
|
res.append({
|
||||||
|
"path": r["path"],
|
||||||
|
"url": url,
|
||||||
|
"sha256": r["sha256"],
|
||||||
|
"size": r["size"]
|
||||||
|
})
|
||||||
|
return {"files": res}
|
||||||
|
|
||||||
|
# 2b. 获取版本 manifest
|
||||||
|
@app.post("/api/v1/update/manifest")
|
||||||
|
async def get_manifest(body: ManifestReq = Body(...)):
|
||||||
|
ver_id = body.version_id
|
||||||
|
conn = db.get_conn()
|
||||||
|
ver = conn.execute(
|
||||||
|
"SELECT app_id, channel, version, create_time FROM versions WHERE id=?",
|
||||||
|
(ver_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not ver:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=404, detail="Version not found")
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT path, sha256, size FROM version_files WHERE version_id=?",
|
||||||
|
(ver_id,)
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for r in rows:
|
||||||
|
files.append({
|
||||||
|
"path": r["path"],
|
||||||
|
"sha256": r["sha256"],
|
||||||
|
"size": r["size"],
|
||||||
|
"executable": is_executable_path(r["path"])
|
||||||
|
})
|
||||||
|
|
||||||
|
manifest = {
|
||||||
|
"app_id": ver["app_id"],
|
||||||
|
"version": ver["version"],
|
||||||
|
"channel": ver["channel"],
|
||||||
|
"platform": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"manifest_seq": int(ver_id),
|
||||||
|
"created_at": ver["create_time"],
|
||||||
|
"files": files
|
||||||
|
}
|
||||||
|
manifest_text = canonical_manifest_bytes(manifest).decode("utf-8")
|
||||||
|
signature = sign_manifest(manifest)
|
||||||
|
manifest["signature"] = signature
|
||||||
|
return {
|
||||||
|
"manifest": manifest,
|
||||||
|
"manifest_text": manifest_text,
|
||||||
|
"signature": signature
|
||||||
|
}
|
||||||
|
|
||||||
|
# 3. 升级结果上报
|
||||||
|
@app.post("/api/v1/update/report")
|
||||||
|
async def report(body: ReportReq = Body(...)):
|
||||||
|
conn = db.get_conn()
|
||||||
|
insert_sql = """
|
||||||
|
INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time)
|
||||||
|
VALUES (?,?,?,?,datetime('now'))
|
||||||
|
"""
|
||||||
|
conn.execute(insert_sql, (body.device_id, body.from_version, body.to_version, body.result))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"code": 0, "msg": "上报成功"}
|
||||||
|
|
||||||
|
# ==================== 管理后台 Admin 全套接口(统一Header鉴权) ====================
|
||||||
|
@app.post("/admin/token/change")
|
||||||
|
def admin_change_token(body: ChangeAdminTokenReq, auth=Depends(admin_auth)):
|
||||||
|
global ADMIN_TOKEN_HASH
|
||||||
|
new_token = body.new_token.strip()
|
||||||
|
if len(new_token) < 8:
|
||||||
|
raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符")
|
||||||
|
if len(new_token) > 128:
|
||||||
|
raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符")
|
||||||
|
new_hash = token_digest(new_token)
|
||||||
|
ADMIN_TOKEN_HASH_PATH.write_text(new_hash, encoding="utf-8")
|
||||||
|
ADMIN_TOKEN_HASH = new_hash
|
||||||
|
return {"code": 0, "msg": "管理员令牌已更新"}
|
||||||
|
|
||||||
|
# 应用列表 GET
|
||||||
|
@app.get("/admin/app/list")
|
||||||
|
def admin_get_app_list(auth=Depends(admin_auth)):
|
||||||
|
conn = db.get_conn()
|
||||||
|
rows = conn.execute("SELECT app_id, app_name FROM apps").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return {"list": [{"app_id": r["app_id"], "app_name": r["app_name"]} for r in rows]}
|
||||||
|
|
||||||
|
# 新增应用 POST json
|
||||||
|
@app.post("/admin/app/add")
|
||||||
|
def admin_add_app(body: dict, auth=Depends(admin_auth)):
|
||||||
|
aid = body["app_id"]
|
||||||
|
aname = body["app_name"]
|
||||||
|
conn = db.get_conn()
|
||||||
|
try:
|
||||||
|
conn.execute("INSERT INTO apps(app_id, app_name) VALUES (?, ?)", (aid, aname))
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
return {"msg": f"创建失败:{str(e)}"}
|
||||||
|
conn.close()
|
||||||
|
return {"msg": "应用创建成功"}
|
||||||
|
|
||||||
|
# 查询和保存应用渠道策略
|
||||||
|
@app.get("/admin/policy")
|
||||||
|
def admin_get_policy(app_id: str, channel: str = "stable", auth=Depends(admin_auth)):
|
||||||
|
conn = db.get_conn()
|
||||||
|
row = conn.execute("SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
result = policy_row_to_dict(row)
|
||||||
|
result.update({"app_id": app_id, "channel": channel})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/policy/save")
|
||||||
|
def admin_save_policy(body: dict, auth=Depends(admin_auth)):
|
||||||
|
app_id = str(body.get("app_id") or "").strip()
|
||||||
|
channel = str(body.get("channel") or "stable").strip()
|
||||||
|
if not app_id or channel not in ("stable", "preview", "dev"):
|
||||||
|
raise HTTPException(status_code=400, detail="App ID 或渠道无效")
|
||||||
|
disabled = body.get("disabled_versions") or []
|
||||||
|
if not isinstance(disabled, list) or any(not isinstance(v, str) for v in disabled):
|
||||||
|
raise HTTPException(status_code=400, detail="disabled_versions 必须是版本字符串数组")
|
||||||
|
valid_until = str(body.get("valid_until") or "").strip()
|
||||||
|
try:
|
||||||
|
datetime.fromisoformat(valid_until.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间")
|
||||||
|
conn = db.get_conn()
|
||||||
|
row = conn.execute("SELECT policy_seq FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone()
|
||||||
|
next_seq = (int(row["policy_seq"]) + 1) if row else 1
|
||||||
|
values = (next_seq, int(bool(body.get("force_update"))), int(bool(body.get("allow_rollback"))),
|
||||||
|
int(bool(body.get("offline_allowed"))), valid_until,
|
||||||
|
str(body.get("min_supported_version") or "").strip(),
|
||||||
|
json.dumps(disabled, ensure_ascii=False), str(body.get("message") or "").strip(), app_id, channel)
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO version_policies(policy_seq,force_update,allow_rollback,offline_allowed,valid_until,
|
||||||
|
min_supported_version,disabled_versions,message,app_id,channel)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(app_id,channel) DO UPDATE SET policy_seq=excluded.policy_seq,
|
||||||
|
force_update=excluded.force_update,allow_rollback=excluded.allow_rollback,
|
||||||
|
offline_allowed=excluded.offline_allowed,valid_until=excluded.valid_until,
|
||||||
|
min_supported_version=excluded.min_supported_version,disabled_versions=excluded.disabled_versions,
|
||||||
|
message=excluded.message,updated_at=datetime('now')
|
||||||
|
""", values)
|
||||||
|
conn.commit(); conn.close()
|
||||||
|
return {"msg": "策略已保存并递增 policy_seq", "policy_seq": next_seq}
|
||||||
|
|
||||||
|
|
||||||
|
# 发布新版本(文件上传)
|
||||||
|
@app.post("/admin/publish")
|
||||||
|
async def admin_publish_version(request: Request, auth=Depends(admin_auth)):
|
||||||
|
# 在 Starlette 解析 multipart 前检查空间;否则大文件 rollover 会直接抛出 Errno 28。
|
||||||
|
content_length_header = request.headers.get("content-length")
|
||||||
|
try:
|
||||||
|
content_length = int(content_length_header or 0)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效的 Content-Length")
|
||||||
|
if content_length <= 0:
|
||||||
|
raise HTTPException(status_code=411, detail="发布请求必须提供 Content-Length")
|
||||||
|
|
||||||
|
spool_free = shutil.disk_usage(UPLOAD_SPOOL_DIR).free
|
||||||
|
storage_free = shutil.disk_usage(MINIO_DATA_DIR).free
|
||||||
|
if spool_free < content_length + 64 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=507, detail={
|
||||||
|
"error": "upload_temp_space_insufficient",
|
||||||
|
"msg": "上传临时空间不足,请减小发布包或扩容 /dev/shm",
|
||||||
|
"required_bytes": content_length + 64 * 1024 * 1024,
|
||||||
|
"available_bytes": spool_free,
|
||||||
|
})
|
||||||
|
if storage_free < content_length + UPLOAD_SPACE_RESERVE:
|
||||||
|
raise HTTPException(status_code=507, detail={
|
||||||
|
"error": "storage_space_insufficient",
|
||||||
|
"msg": "版本存储空间不足,请扩容服务器根卷或删除不再需要的历史版本",
|
||||||
|
"required_bytes": content_length + UPLOAD_SPACE_RESERVE,
|
||||||
|
"available_bytes": storage_free,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 手动解析表单,增加兼容性并便于调试前端上传问题
|
||||||
|
try:
|
||||||
|
form = await request.form()
|
||||||
|
except OSError as err:
|
||||||
|
if getattr(err, "errno", None) == 28:
|
||||||
|
raise HTTPException(status_code=507, detail="上传临时空间已满,请扩容后重试")
|
||||||
|
raise
|
||||||
|
# 尝试从 form 中获取字段
|
||||||
|
app_id = form.get("app_id")
|
||||||
|
channel = form.get("channel") or "stable"
|
||||||
|
version = form.get("version")
|
||||||
|
|
||||||
|
# 收集 files(可能是多个同名字段)
|
||||||
|
files = []
|
||||||
|
if hasattr(form, "getlist"):
|
||||||
|
files = form.getlist("files")
|
||||||
|
else:
|
||||||
|
# 兼容性降级:遍历所有表单项
|
||||||
|
for k, v in form.multi_items():
|
||||||
|
if k == "files":
|
||||||
|
files.append(v)
|
||||||
|
|
||||||
|
# 如果关键信息缺失,返回详细调试信息,方便前端定位问题
|
||||||
|
if not app_id or not version:
|
||||||
|
headers = {k: v for k, v in request.headers.items()}
|
||||||
|
detail = {
|
||||||
|
"error": "missing form fields",
|
||||||
|
"have_app_id": bool(app_id),
|
||||||
|
"have_version": bool(version),
|
||||||
|
"content_type": request.headers.get("content-type"),
|
||||||
|
"headers": headers,
|
||||||
|
"form_keys": list(form.keys()),
|
||||||
|
"app_id_value": app_id,
|
||||||
|
"version_value": version,
|
||||||
|
"app_id_len": len(app_id or ""),
|
||||||
|
"version_len": len(version or "")
|
||||||
|
}
|
||||||
|
raise HTTPException(status_code=422, detail=detail)
|
||||||
|
|
||||||
|
if not files:
|
||||||
|
raise HTTPException(status_code=400, detail="未选择任何文件,请选择程序文件夹")
|
||||||
|
|
||||||
|
relative_paths = form.getlist("relative_paths") if hasattr(form, "getlist") else []
|
||||||
|
if relative_paths and len(relative_paths) != len(files):
|
||||||
|
raise HTTPException(status_code=400, detail="文件数量与相对路径数量不一致")
|
||||||
|
|
||||||
|
upload_items = []
|
||||||
|
seen_paths = set()
|
||||||
|
for index, file in enumerate(files):
|
||||||
|
if not hasattr(file, "filename"):
|
||||||
|
raise HTTPException(status_code=400, detail="上传内容中包含无效文件")
|
||||||
|
raw_path = relative_paths[index] if relative_paths else file.filename
|
||||||
|
rel_path = normalize_relative_path(str(raw_path))
|
||||||
|
path_key = rel_path.lower()
|
||||||
|
if path_key in seen_paths:
|
||||||
|
raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}")
|
||||||
|
seen_paths.add(path_key)
|
||||||
|
upload_items.append((file, rel_path))
|
||||||
|
|
||||||
|
if "mainapp.exe" not in seen_paths:
|
||||||
|
nested_main = next((path for path in seen_paths if path.endswith("/mainapp.exe")), None)
|
||||||
|
if nested_main:
|
||||||
|
raise HTTPException(status_code=400, detail=f"MainApp.exe 不在发布根级,请改为选择其所在目录: {nested_main}")
|
||||||
|
raise HTTPException(status_code=400, detail="发布根目录中缺少 MainApp.exe")
|
||||||
|
|
||||||
|
|
||||||
|
conn = db.get_conn()
|
||||||
|
publish_started = False
|
||||||
|
publish_prefix = f"{app_id}/{channel}/{version}/"
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
exists = cur.execute(
|
||||||
|
"SELECT 1 FROM versions WHERE app_id=? AND channel=? AND version=?",
|
||||||
|
(app_id, channel, version)
|
||||||
|
).fetchone()
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
|
||||||
|
|
||||||
|
cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel))
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO versions(app_id, channel, version, latest) VALUES (?,?,?,1)",
|
||||||
|
(app_id, channel, version)
|
||||||
|
)
|
||||||
|
new_vid = cur.lastrowid
|
||||||
|
publish_started = True
|
||||||
|
|
||||||
|
for file, rel_path in upload_items:
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
f_size = len(content)
|
||||||
|
f_sha = calc_sha256(content)
|
||||||
|
obj_path = f"{app_id}/{channel}/{version}/files/{rel_path}"
|
||||||
|
put_result = minio_tool.put_file(obj_path, content, f_size)
|
||||||
|
if put_result.get('storage') == 'local':
|
||||||
|
print(f"MinIO 不可用,已保存到本地回退:{put_result.get('path')}")
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO version_files(version_id, path, sha256, size) VALUES (?,?,?,?)",
|
||||||
|
(new_vid, rel_path, f_sha, f_size)
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.IntegrityError as err:
|
||||||
|
conn.rollback()
|
||||||
|
if publish_started:
|
||||||
|
minio_tool.remove_prefix(publish_prefix)
|
||||||
|
if 'UNIQUE constraint failed: versions.app_id, versions.channel, versions.version' in str(err):
|
||||||
|
raise HTTPException(status_code=400, detail="该版本已存在,请更换版本号或先删除旧版本")
|
||||||
|
raise HTTPException(status_code=500, detail=str(err))
|
||||||
|
except sqlite3.OperationalError as err:
|
||||||
|
conn.rollback()
|
||||||
|
if publish_started:
|
||||||
|
minio_tool.remove_prefix(publish_prefix)
|
||||||
|
raise HTTPException(status_code=500, detail={"error": "database locked", "msg": str(err)})
|
||||||
|
except HTTPException:
|
||||||
|
conn.rollback()
|
||||||
|
if publish_started:
|
||||||
|
minio_tool.remove_prefix(publish_prefix)
|
||||||
|
raise
|
||||||
|
except Exception as err:
|
||||||
|
conn.rollback()
|
||||||
|
if publish_started:
|
||||||
|
minio_tool.remove_prefix(publish_prefix)
|
||||||
|
if isinstance(err, OSError) and getattr(err, "errno", None) == 28:
|
||||||
|
raise HTTPException(status_code=507, detail="发布过程中存储空间耗尽,已清理未完成版本")
|
||||||
|
raise HTTPException(status_code=500, detail={"error": "publish_failed", "msg": str(err)})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件"}
|
||||||
|
|
||||||
|
# 获取版本列表 GET
|
||||||
|
@app.get("/admin/version/list")
|
||||||
|
def admin_get_version_list(app_id: str, auth=Depends(admin_auth)):
|
||||||
|
conn = db.get_conn()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT id, version, channel, latest, create_time
|
||||||
|
FROM versions WHERE app_id=? ORDER BY create_time DESC
|
||||||
|
""", (app_id,)).fetchall()
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
out.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"version": r["version"],
|
||||||
|
"channel": r["channel"],
|
||||||
|
"latest": bool(r["latest"]),
|
||||||
|
"create_time": r["create_time"]
|
||||||
|
})
|
||||||
|
return {"list": out}
|
||||||
|
|
||||||
|
# 设置指定版本为渠道最新 POST json
|
||||||
|
@app.post("/admin/version/set-latest")
|
||||||
|
def admin_set_latest(body: dict, auth=Depends(admin_auth)):
|
||||||
|
vid = body["version_id"]
|
||||||
|
conn = db.get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
v_info = cur.execute("SELECT app_id, channel FROM versions WHERE id=?", (vid,)).fetchone()
|
||||||
|
aid, ch = v_info["app_id"], v_info["channel"]
|
||||||
|
cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (aid, ch))
|
||||||
|
cur.execute("UPDATE versions SET latest=1 WHERE id=?", (vid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {"msg": "已切换为渠道最新版本"}
|
||||||
|
|
||||||
|
# 删除版本 POST json
|
||||||
|
@app.post("/admin/version/delete")
|
||||||
|
def admin_delete_version(body: dict, auth=Depends(admin_auth)):
|
||||||
|
vid = body["version_id"]
|
||||||
|
conn = db.get_conn()
|
||||||
|
cur = conn.cursor()
|
||||||
|
v_info = cur.execute("SELECT app_id, channel, version, latest FROM versions WHERE id=?", (vid,)).fetchone()
|
||||||
|
if not v_info:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=404, detail="版本不存在")
|
||||||
|
aid, ch, ver = v_info["app_id"], v_info["channel"], v_info["version"]
|
||||||
|
was_latest = bool(v_info["latest"])
|
||||||
|
# 删除minio下该版本全部文件
|
||||||
|
prefix = f"{aid}/{ch}/{ver}/files/"
|
||||||
|
minio_tool.remove_prefix(prefix)
|
||||||
|
# 删除数据库记录
|
||||||
|
cur.execute("DELETE FROM version_files WHERE version_id=?", (vid,))
|
||||||
|
cur.execute("DELETE FROM versions WHERE id=?", (vid,))
|
||||||
|
promoted_version = None
|
||||||
|
if was_latest:
|
||||||
|
replacement = cur.execute(
|
||||||
|
"SELECT id, version FROM versions WHERE app_id=? AND channel=? ORDER BY create_time DESC, id DESC LIMIT 1",
|
||||||
|
(aid, ch)
|
||||||
|
).fetchone()
|
||||||
|
if replacement:
|
||||||
|
cur.execute("UPDATE versions SET latest=1 WHERE id=?", (replacement["id"],))
|
||||||
|
promoted_version = replacement["version"]
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"msg": "版本、云端文件、数据库记录全部删除完成",
|
||||||
|
"promoted_latest": promoted_version
|
||||||
|
}
|
||||||
|
|
||||||
|
# 获取升级日志 GET
|
||||||
|
@app.get("/admin/report/list")
|
||||||
|
def admin_get_report_log(auth=Depends(admin_auth)):
|
||||||
|
conn = db.get_conn()
|
||||||
|
rows = conn.execute("""
|
||||||
|
SELECT device_id,from_ver,to_ver,result,create_time
|
||||||
|
FROM upgrade_logs ORDER BY create_time DESC LIMIT 200
|
||||||
|
""").fetchall()
|
||||||
|
conn.close()
|
||||||
|
res = []
|
||||||
|
for r in rows:
|
||||||
|
res.append({
|
||||||
|
"device_id": r["device_id"],
|
||||||
|
"app_id": "",
|
||||||
|
"from_version": r["from_ver"],
|
||||||
|
"to_version": r["to_ver"],
|
||||||
|
"result": r["result"],
|
||||||
|
"create_time": r["create_time"]
|
||||||
|
})
|
||||||
|
return {"list": res}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1)
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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 urllib3.util import Retry, Timeout
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
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"))
|
||||||
|
LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE")
|
||||||
|
LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
http_client = urllib3.PoolManager(
|
||||||
|
timeout=Timeout(connect=2.0, read=5.0),
|
||||||
|
retries=Retry(total=1, connect=1, read=1, status=1, backoff_factor=0.2),
|
||||||
|
)
|
||||||
|
|
||||||
|
mc = Minio(
|
||||||
|
MINIO_ENDPOINT,
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
secure=False,
|
||||||
|
http_client=http_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_url(file_path: str) -> str:
|
||||||
|
bucket = MINIO_BUCKET
|
||||||
|
full_object_path = file_path
|
||||||
|
try:
|
||||||
|
mc.stat_object(bucket, full_object_path)
|
||||||
|
url = 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}"
|
||||||
|
return f"http://{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 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}")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- 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=历史旧版
|
||||||
|
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)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user