This commit is contained in:
2026-07-01 03:32:41 +00:00
commit cd3dca042d
45 changed files with 8072 additions and 0 deletions
View File
+6
View File
@@ -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)
+191
View File
@@ -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;
}
+70
View File
@@ -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()
+32
View File
@@ -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
)
+116
View File
@@ -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;
}
+16
View File
@@ -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;
};
+42
View File
@@ -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);
}
+14
View File
@@ -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);
};
+49
View File
@@ -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();
}
+17
View File
@@ -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);
};
+147
View File
@@ -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;
}
+33
View File
@@ -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;
};
+115
View File
@@ -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(); }
+32
View File
@@ -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;
};
+46
View File
@@ -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()
+114
View File
@@ -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;
});
}
+38
View File
@@ -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;
};
+143
View File
@@ -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;
}
+21
View File
@@ -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()
+72
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <QWidget>
#include <QLabel>
#include <QVBoxLayout>
class MainWindow : public QWidget
{
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
};
+110
View File
@@ -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();
}
+3
View File
@@ -0,0 +1,3 @@
client目录下的CMakeList.txt用来统一管理client下的Launcher工程和MainApp工程和Updater工程
cofig.ini要跟exe同级目录
+41
View File
@@ -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和QtOpenSSL库由Common INTERFACE自动传递
target_link_libraries(Updater PRIVATE
Common
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
)
# Qt部署脚本
if(WIN32)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
add_custom_command(TARGET Updater POST_BUILD
COMMAND ${WINDEPLOYQT} --debug $<TARGET_FILE:Updater>
)
endif()
+269
View File
@@ -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;
}
+54
View File
@@ -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;
};
+670
View File
@@ -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;
}
+69
View File
@@ -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;
};
+325
View File
@@ -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;
}
+622
View File
@@ -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>
+14
View File
@@ -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
+14
View File
@@ -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": ""
}
+6
View File
@@ -0,0 +1,6 @@
{
"max_policy_seq": 0,
"last_success_run_at": "",
"last_online_verified_at": "",
"last_success_version": ""
}
+9
View File
@@ -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-----
+1
View File
@@ -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
+538
View File
@@ -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 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。