新增了客户端打包成sdk的功能和服务端docker镜像打包的功能,并修复了一些问题

This commit is contained in:
2026-07-07 09:48:01 +00:00
parent cd3dca042d
commit 9e545cb328
50 changed files with 5251 additions and 875 deletions
+37 -31
View File
@@ -1,32 +1,38 @@
project(Common LANGUAGES C CXX)
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
set(SRC
HttpHelper.h
HttpHelper.cpp
FileHelper.h
FileHelper.cpp
ConfigHelper.h
ConfigHelper.cpp
PolicyHelper.h
PolicyHelper.cpp
LocalStateHelper.h
LocalStateHelper.cpp
)
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
project(Common LANGUAGES C CXX)
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
set(SRC
HttpHelper.h
HttpHelper.cpp
FileHelper.h
FileHelper.cpp
ConfigHelper.h
ConfigHelper.cpp
PolicyHelper.h
PolicyHelper.cpp
LocalStateHelper.h
LocalStateHelper.cpp
TicketHelper.h
TicketHelper.cpp
IntegrityHelper.h
IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
)
add_library(Common STATIC ${SRC})
# Common编译自身需要OpenSSL头文件
target_include_directories(Common
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${OPENSSL_INC}
)
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
target_link_directories(Common INTERFACE
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
)
target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
INTERFACE libssl.lib libcrypto.lib
)
+7
View File
@@ -95,12 +95,19 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
copyText("App", "app_name", "Marsco Demo App");
copyText("App", "channel", "stable");
copyText("App", "current_version", "1.0.0");
copyText("App", "client_protocol", "3");
copyText("App", "launch_token");
copyText("License", "license_key");
copyText("Server", "api_base_url");
copyText("Server", "client_token");
copyText("Update", "request_timeout_ms", "5000");
copyText("Update", "temp_folder", "update_temp");
copyText("Update", "device_id");
copyText("Runtime", "main_executable", "MainApp.exe");
copyText("Runtime", "launcher_executable", "Launcher.exe");
copyText("Runtime", "updater_executable", "Updater.exe");
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
copyText("Runtime", "health_check_timeout_ms", "15000");
config.insert("platform", "windows");
config.insert("arch", "x64");
+41
View File
@@ -0,0 +1,41 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QUuid>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
QString installation=ConfigHelper::instance().getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!ConfigHelper::instance().setValue("Device","installation_id",installation)){m_error="cannot save installation id";return false;}}
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
QNetworkAccessManager manager;QNetworkRequest req{QUrl(base+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);loop.exec();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error="cannot save device credential";return false;}if(!loadAndVerify(appId,channel))return false;if(!ConfigHelper::instance().setValue("Update","device_id",m_deviceId)){m_error="cannot save server device id";return false;}return true;
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error;
};
+52 -48
View File
@@ -1,49 +1,53 @@
#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();
#include "HttpHelper.h"
#include <QNetworkProxy>
#include "ConfigHelper.h"
#include <QFile>
#include <QDir>
#include <QApplication>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
int retCode = 0;
QJsonObject retObj;
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
retObj = QJsonDocument::fromJson(respData).object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
}
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
}
+132
View File
@@ -0,0 +1,132 @@
#include "IntegrityHelper.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {}
QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::safeRelativePath(const QString& path) const
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
}
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
static const QSet<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
return protectedPaths.contains(p);
}
QString IntegrityHelper::sha256(const QString& filePath) const
{
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return {};
QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd()) hash.addData(file.read(1024 * 1024));
return QString::fromLatin1(hash.result().toHex());
}
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
#else
QFile keyFile(QDir(m_installDir).filePath("config/manifest_public_key.pem"));
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
const QByteArray keyData = keyFile.readAll();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) BIO_free(bio);
if (!key) { m_error = "manifest public key invalid"; return false; }
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
if (ctx) EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) m_error = "manifest RSA signature invalid";
return ok;
#endif
}
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version)
{
m_error.clear();
const QString cachePath = QDir(m_installDir).filePath(
"update/manifest_cache/manifest_" + version + ".json");
QFile cache(cachePath);
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
QJsonParseError wrapperError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = "manifest cache JSON invalid"; return false;
}
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false;
QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
m_error = "signed manifest payload invalid"; return false;
}
const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = "manifest identity does not match local application"; return false;
}
QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; }
if (runtimeProtectedPath(path)) continue;
const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; }
const QString expected = item.value("sha256").toString();
const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
m_error = "file hash mismatch: " + path; return false;
}
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
}
QDir root(m_installDir);
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
while (it.hasNext()) {
const QString fullPath = it.next();
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
const QString folded = relative.toCaseFolded();
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = "undeclared executable or plugin: " + relative; return false;
}
}
return true;
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
class IntegrityHelper
{
public:
explicit IntegrityHelper(const QString& installDir);
bool verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version);
QString errorString() const;
private:
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
bool safeRelativePath(const QString& path) const;
bool runtimeProtectedPath(const QString& path) const;
QString sha256(const QString& filePath) const;
QString m_installDir;
QString m_error;
};
+1
View File
@@ -105,6 +105,7 @@ bool PolicyHelper::isVersionAllowed(const QString& version) const {
}
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); }
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
bool PolicyHelper::isExpired() const {
if (!isValid()) return true;
+1
View File
@@ -15,6 +15,7 @@ public:
bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const;
bool forceUpdate() const;
bool allowRollback() const;
bool isOfflineAllowed() const;
bool isExpired() const;
qint64 policySeq() const;
+91
View File
@@ -0,0 +1,91 @@
#include "TicketHelper.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMessageAuthenticationCode>
#include <QSaveFile>
#include <QStandardPaths>
#include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
{
return QMessageAuthenticationCode::hash(
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
}
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage)
{
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
return false;
}
const QDateTime now = QDateTime::currentDateTimeUtc();
QJsonObject payload{
{"app_id", appId}, {"device_id", deviceId}, {"version", version},
{"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)},
{"issued_at", now.toString(Qt::ISODate)},
{"expires_at", now.addSecs(60).toString(Qt::ISODate)},
{"signature_alg", "HMAC-SHA256"}
};
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
QSaveFile file(path);
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
if (errorMessage) *errorMessage = "cannot save ticket";
return false;
}
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
if (ticketPath) *ticketPath = path;
return true;
}
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage)
{
const QString consumingPath = ticketPath + ".consuming."
+ QString::number(QCoreApplication::applicationPid());
if (!QFile::rename(ticketPath, consumingPath)) {
if (errorMessage) *errorMessage = "ticket missing or already consumed";
return false;
}
QFile file(consumingPath);
if (!file.open(QIODevice::ReadOnly)) {
QFile::remove(consumingPath);
if (errorMessage) *errorMessage = "cannot read claimed ticket";
return false;
}
const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false;
}
const QJsonObject wrapper = doc.object();
const QJsonObject payload = wrapper.value("payload").toObject();
const QByteArray actual = wrapper.value("signature").toString().toLatin1();
const QByteArray expected = ticketMac(payload, secret);
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
const QDateTime now = QDateTime::currentDateTimeUtc();
const bool identityOk = payload.value("app_id").toString() == expectedAppId
&& payload.value("device_id").toString() == expectedDeviceId
&& payload.value("version").toString() == expectedVersion;
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
&& expires >= now && issued.secsTo(expires) <= 65;
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk
|| payload.value("nonce").toString().isEmpty()) {
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
return false;
}
return true;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QString>
class TicketHelper
{
public:
static bool createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage = nullptr);
static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage = nullptr);
};
+2 -2
View File
@@ -40,7 +40,7 @@ if(WIN32)
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>
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Launcher>
COMMENT "自动部署Qt依赖dll"
)
endif()
endif()
+3
View File
@@ -33,6 +33,8 @@ void UpdateLogic::checkUpdate()
body["app_id"] = m_appId;
body["current_version"] = m_curVer;
body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
{
@@ -101,6 +103,7 @@ void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fro
{
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = m_appId;
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
+68 -6
View File
@@ -9,6 +9,10 @@
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h"
#include <QFile>
#include <QFileDialog>
int main(int argc, char* argv[])
{
@@ -26,6 +30,18 @@ int main(int argc, char* argv[])
progress.show();
QApplication::processEvents();
ConfigHelper& config = ConfigHelper::instance();
DeviceIdentityHelper identity(QApplication::applicationDirPath());
if (!identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"),
config.getValue("App", "app_id"),
config.getValue("App", "channel"),
config.getValue("License", "license_key"))) {
progress.close();
QMessageBox::critical(nullptr, "设备身份验证失败", identity.errorString());
return -1;
}
UpdateLogic logic;
logic.checkUpdate();
@@ -37,13 +53,49 @@ int main(int argc, char* argv[])
const QString appId = logic.getAppId();
const QString channel = logic.getChannel();
ConfigHelper& config = ConfigHelper::instance();
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close();
const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
QMessageBox::critical(nullptr, "授权被拒绝", message.isEmpty() ? "设备或 License 授权无效。" : message);
return -1;
}
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)};
const auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
};
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
const auto importOfflinePackage = [&]() {
const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)");
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
};
const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close();
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。");
return 0;
}
const auto startMainApp = [&]() {
QString ticketPath;
QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
return false;
}
const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) QFile::remove(ticketPath);
return started;
};
progress.setLabelText("正在验证本地运行策略...");
QApplication::processEvents();
@@ -108,7 +160,7 @@ int main(int argc, char* argv[])
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
}
if (!accepted) {
if (!QProcess::startDetached(mainAppPath, mainArgs)) {
if (!startMainApp()) {
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
return -1;
}
@@ -123,6 +175,16 @@ int main(int argc, char* argv[])
return 0;
}
if (!networkOk) {
progress.close();
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?",
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。");
return 0;
}
progress.show();
}
if (!networkOk && !policy.isOfflineAllowed())
{
progress.close();
@@ -132,7 +194,7 @@ int main(int argc, char* argv[])
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
QApplication::processEvents();
if (!QProcess::startDetached(mainAppPath, mainArgs))
if (!startMainApp())
{
progress.close();
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
+2 -2
View File
@@ -16,6 +16,6 @@ if(WIN32)
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>
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
)
endif()
endif()
+38 -19
View File
@@ -10,6 +10,9 @@
#include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h"
int main(int argc, char* argv[])
{
@@ -20,35 +23,40 @@ int main(int argc, char* argv[])
qDebug() << Qt::endl << "entered main app" << Qt::endl;
const QString validToken = ConfigHelper::instance().getValue("App", "launch_token");
bool pass = false;
ConfigHelper& config = ConfigHelper::instance();
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
QString ticketFilePath;
QString healthFilePath;
for (int i = 0; i < argc; ++i)
for (int i = 1; i < argc; ++i)
{
QString arg(argv[i]);
if (arg.startsWith("--launcher-token="))
{
QString tk = arg.split("=").last();
if (tk == validToken)
{
pass = true;
}
}
const QString arg(argv[i]);
if (arg.startsWith("--ticket-file="))
ticketFilePath = arg.mid(QString("--ticket-file=").size());
else if (arg.startsWith("--health-file="))
{
healthFilePath = arg.mid(QString("--health-file=").size());
}
}
if (!pass)
QString ticketError;
if (ticketFilePath.isEmpty()
|| !TicketHelper::consumeAndVerify(ticketFilePath,
config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
&ticketError))
{
QMessageBox::critical(nullptr, "Startup Restriction", "Direct double-clicking MainApp.exe is prohibited. Please use Launcher.exe to open the software!");
QMessageBox::critical(nullptr, "Startup Restriction",
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.")
.arg(ticketError, launcherExecutable));
return -1;
}
QString appDir = QApplication::applicationDirPath();
DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
{
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
return -1;
}
PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{
@@ -81,6 +89,17 @@ int main(int argc, char* argv[])
return -1;
}
IntegrityHelper integrity(appDir);
if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version")))
{
QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Application files failed signed Manifest verification:\n%1")
.arg(integrity.errorString()));
return -1;
}
MainWindow w;
w.show();
@@ -107,4 +126,4 @@ int main(int argc, char* argv[])
qDebug() << "Main program MainApp is running normally";
return a.exec();
}
}
+27 -3
View File
@@ -1,3 +1,27 @@
client目录下的CMakeList.txt用来统一管理client下的Launcher工程和MainApp工程和Updater工程
cofig.ini要跟exe同级目录
客户端配置说明
==============
统一从程序目录下的 config/app_config.json 读取配置。
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
接入新软件时通常需要修改:
1. app_id、app_name、channel、current_version。
2. api_base_url、client_token、license_key、launch_token。
3. main_executable:团队业务主程序文件名。
4. launcher_executable、updater_executable、bootstrap_executable。
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
完整格式参考 config/app_config.example.json。
运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。
Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
4. 在 PowerShell 执行:
powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
+129
View File
@@ -0,0 +1,129 @@
# UpdateClientSDK 接入说明
这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。
SDK 核心程序:
- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。
- `config/app_config.json`:接入方配置。
- `config/manifest_public_key.pem`:验证服务端签名用的公钥。
## 接入方需要做什么
假设接入的软件叫 `YourApp.exe`
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`
2. 创建或确认渠道,例如 `stable`
3. 创建 License,把生成的 `license_key` 填到客户端配置。
4. 把业务程序和依赖 DLL 放到同一个发布目录。
5. 把 SDK 的 `Launcher.exe``Updater.exe``Bootstrap.exe` 和运行时 DLL 放到该目录。
6.`config/app_config.example.json` 复制成 `config/app_config.json` 并修改字段。
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
## app_config.json 关键字段
```json
{
"app_id": "simcae",
"app_name": "SimCAE",
"channel": "stable",
"current_version": "1.0.0",
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": "",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
```
字段说明:
- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。
- `channel`:发布渠道,例如 `stable``beta``dev`
- `current_version`:当前客户端初始版本。
- `client_protocol`:客户端协议号,当前建议为 `3`
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
- `main_executable`:业务主程序文件名,默认填 `SimCAE.exe`;如果你的主程序不是这个名字,这里同步修改。
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
## 业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了:
- 启动票据校验。
- 本地 License/设备身份校验。
- 本地策略校验。
- Manifest 完整性校验。
- 健康标记写入。
如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。
## 如何生成 SDK 包
在 Windows PowerShell 中执行:
```powershell
cd client
.\package-sdk.ps1 `
-SourceDir .\out\bin `
-OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0
```
生成结果:
```text
dist/UpdateClientSDK/
README.md
bin/
config/
scripts/
samples/
```
`UpdateClientSDK.zip` 发给接入方即可。
## 如何生成某个产品的最终客户端包
SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用:
```powershell
cd client
.\package-client.ps1 `
-SourceDir .\out\bin `
-ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip
```
`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。
## 常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动设备登记失败:检查 `api_base_url``client_token``license_key`、服务端 License 状态。
3. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
4. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
5. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
Binary file not shown.
Binary file not shown.
+9 -9
View File
@@ -1,9 +1,9 @@
# 强制所有编译、设计时生成均使用x64,禁止Win32
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
# 强制所有编译、设计时生成均使用x64,禁止Win32
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
# 关闭VS自动Win32设计时预生成
# 关闭VS自动Win32设计时预生成
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
# 清除32位Strawberry Perl路径干扰
# 清除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")
@@ -21,21 +21,21 @@ if(WIN32)
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
endif()
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
# 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递
# 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递
target_link_libraries(Updater PRIVATE
Common
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
)
# Qt部署脚本
# 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>
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
)
endif()
endif()
+60
View File
@@ -12,6 +12,7 @@
#include <QCryptographicHash>
#include <QDir>
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
#include <algorithm>
#include "ConfigHelper.h"
@@ -325,6 +326,52 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
return true;
}
bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir)
{
m_offlineError.clear();
QFile package(packagePath);
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; }
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; }
const QByteArray lengthBytes = package.read(8);
if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; }
quint64 headerSize = 0;
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; }
QJsonParseError error;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; }
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem";
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; }
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; }
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; }
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; }
manifest.insert("signature", wrapper.value("manifest_signature").toString());
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; }
if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; }
const qint64 payloadStart = 16 + qint64(headerSize);
const QJsonArray entries = packageMeta.value("files").toArray();
for (const QJsonValue& value : entries) {
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; }
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
if (stagingDir.isEmpty()) continue;
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; }
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; }
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); }
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; }
}
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; }
return true;
}
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{
QString url = m_serverAddr + "/api/v1/update/download-url";
@@ -640,6 +687,18 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
return true;
}
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
const QString& version, bool success)
{
QJsonArray files;
for (const FileDownloadItem& item : m_fileItems)
files.append(QJsonObject{{"path", item.path}, {"size", item.size}});
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version},
{"result", success ? "success" : "fail"}, {"files", files}};
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
}
void UpdaterLogic::reportResult(const QString& deviceId,
const QString& fromVer,
const QString& toVer,
@@ -648,6 +707,7 @@ void UpdaterLogic::reportResult(const QString& deviceId,
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id");
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
+4
View File
@@ -29,9 +29,12 @@ public:
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
bool saveManifestCache(const QString& cacheDir) const;
bool loadManifestCache(const QString& cacheDir, const QString& version);
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
QString offlineError() const { return m_offlineError; }
void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success);
bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
QString calcLocalFileSha256(const QString& filePath) const;
@@ -65,5 +68,6 @@ private:
qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath;
QString m_offlineError;
QElapsedTimer m_downloadTimer;
};
+115 -36
View File
@@ -17,6 +17,9 @@
#include "UpdateTransaction.h"
#include "FileHelper.h"
#include "ConfigHelper.h"
#include "TicketHelper.h"
#include "PolicyHelper.h"
#include <QVersionNumber>
int main(int argc, char* argv[])
{
@@ -25,15 +28,30 @@ int main(int argc, char* argv[])
QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater");
if (argc < 5) {
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少应用、渠道或目标版本参数,请从 Launcher 启动。");
return -1;
UpdaterLogic logic;
QString offlinePackagePath;
QString appId;
QString channel;
QString targetVersion;
int targetVersionId = 0;
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
if (!logic.loadOfflinePackage(offlinePackagePath)) {
QMessageBox::critical(nullptr, "离线包无效", logic.offlineError());
return -1;
}
const QJsonObject packageManifest = logic.getManifest();
appId = packageManifest.value("app_id").toString();
channel = packageManifest.value("channel").toString();
targetVersion = packageManifest.value("version").toString();
targetVersionId = packageManifest.value("manifest_seq").toInt();
} else {
if (argc < 5) {
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。");
return -1;
}
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
}
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];
@@ -44,7 +62,25 @@ int main(int argc, char* argv[])
const QString targetDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance();
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
return -1;
}
QString fromVersion = config.getValue("App", "current_version");
if (!offlinePackagePath.isEmpty()) {
PolicyHelper offlinePolicy(targetDir);
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
return -1;
}
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
QVersionNumber::fromString(fromVersion)) < 0
&& !offlinePolicy.allowRollback()) {
QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。");
return -1;
}
}
QString deviceId = config.getValue("Update", "device_id");
if (deviceId.isEmpty()) deviceId = "unknown_device";
@@ -75,7 +111,6 @@ int main(int argc, char* argv[])
progress.show();
QApplication::processEvents();
UpdaterLogic logic;
UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId);
const auto setProgress = [&](int value, const QString& message) {
progress.setValue(value);
@@ -103,22 +138,46 @@ int main(int argc, char* argv[])
formatBytes(static_cast<qint64>(bytesPerSecond))));
QApplication::processEvents();
});
const auto reportUpdateResult = [&](bool success) {
if (offlinePackagePath.isEmpty())
logic.reportResult(deviceId, fromVersion, targetVersion, success);
};
const auto fail = [&](const QString& title, const QString& message,
const QString& errorCode = QString("update_failed")) {
transaction.markFailed(errorCode, message);
logic.reportResult(deviceId, fromVersion, targetVersion, false);
reportUpdateResult(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 auto configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value;
};
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
bool timeoutOk = false;
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
const QString mainAppPath = QDir(targetDir).filePath(mainExecutable);
const QString updaterPath = QDir(targetDir).filePath(updaterExecutable);
const QString bootstrapPath = QDir(targetDir).filePath(bootstrapExecutable);
const QString launchToken = config.getValue("App", "launch_token");
const auto launchMainApp = [&](const QString& healthFile = QString()) {
QStringList args{QString("--launcher-token=%1").arg(launchToken)};
QString ticketPath;
QString ticketError;
const QString launchVersion = config.getValue("App", "current_version");
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
&ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError;
return false;
}
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
return QProcess::startDetached(mainAppPath, args);
const bool started = QProcess::startDetached(mainAppPath, args);
if (!started) QFile::remove(ticketPath);
return started;
};
const auto bootstrapPlanFile = [&]() {
return QDir(targetDir).filePath("update/bootstrap_plan_" + transaction.transactionId() + ".txt");
@@ -132,7 +191,7 @@ int main(int argc, char* argv[])
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
};
const auto delegateRollback = [&](const QString& title, const QString& reason) {
FileHelper::killProcess("MainApp.exe");
FileHelper::killProcess(mainExecutable);
transaction.markRollbackRequired(reason);
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
QApplication::processEvents();
@@ -140,7 +199,7 @@ int main(int argc, char* argv[])
progress.close();
return 0;
}
logic.reportResult(deviceId, fromVersion, targetVersion, false);
reportUpdateResult(false);
progress.close();
QMessageBox::critical(nullptr, title,
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
@@ -150,17 +209,19 @@ int main(int argc, char* argv[])
if (resumingFromBootstrap) {
QString resumeError;
if (!transaction.resumeExisting(&resumeError)) {
logic.reportResult(deviceId, fromVersion, targetVersion, false);
reportUpdateResult(false);
progress.close();
QMessageBox::critical(nullptr, "事务续办失败",
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
return -1;
}
fromVersion = transaction.fromVersion();
if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode")))
offlinePackagePath = "__bootstrap_resumed_offline__";
if (bootstrapResult == "rolledback") {
const bool stateOk = config.setValue("App", "current_version", fromVersion);
transaction.markRolledBack();
logic.reportResult(deviceId, fromVersion, targetVersion, false);
reportUpdateResult(false);
if (stateOk) launchMainApp();
progress.close();
QMessageBox::warning(nullptr, "更新已回滚",
@@ -169,7 +230,7 @@ int main(int argc, char* argv[])
return stateOk ? 0 : -1;
}
if (bootstrapResult != "success") {
logic.reportResult(deviceId, fromVersion, targetVersion, false);
reportUpdateResult(false);
progress.close();
QMessageBox::critical(nullptr, "自动回滚失败",
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
@@ -177,16 +238,25 @@ int main(int argc, char* argv[])
}
} else if (!transaction.initialize()) {
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
} else if (!offlinePackagePath.isEmpty()) {
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed");
}
setProgress(resumingFromBootstrap ? 72 : 10, "正在获取并验证版本清单...");
logic.getManifest(appId, channel, targetVersion, targetVersionId);
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache");
if (resumingFromBootstrap) {
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
} else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId);
}
if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap)
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
}
const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache");
QStringList obsoletePaths;
if (!resumingFromBootstrap && fromVersion != targetVersion) {
UpdaterLogic oldManifestLogic;
@@ -206,8 +276,9 @@ int main(int argc, char* argv[])
}
if (!resumingFromBootstrap) {
setProgress(25, "正在获取安全下载地址...");
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件...");
if (offlinePackagePath.isEmpty())
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
if (logic.getFileList().isEmpty())
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
@@ -224,9 +295,17 @@ int main(int argc, char* argv[])
}
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(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size()));
if (!offlinePackagePath.isEmpty()) {
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid");
} else {
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
logic.reportDownloadResult(appId, channel, targetVersion, false);
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
}
logic.reportDownloadResult(appId, channel, targetVersion, true);
}
setProgress(58, "正在校验完整版本文件...");
if (!logic.validateLocalFiles(stagingDir, targetDir))
@@ -238,15 +317,15 @@ int main(int argc, char* argv[])
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 (path.compare(bootstrapExecutable, Qt::CaseInsensitive) == 0)
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapExecutable), "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");
if (!FileHelper::killProcess(mainExecutable))
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed");
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
@@ -300,16 +379,16 @@ int main(int argc, char* argv[])
QFile::remove(healthFile);
setProgress(94, "正在启动新版本并等待健康确认...");
if (!launchMainApp(healthFile))
return delegateRollback("启动失败", "MainApp.exe 无法启动。");
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable));
QElapsedTimer healthTimer;
healthTimer.start();
while (healthTimer.elapsed() < 15000 && !QFile::exists(healthFile)) {
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
QApplication::processEvents();
QThread::msleep(100);
}
if (!QFile::exists(healthFile))
return delegateRollback("启动确认失败", "新版本在 15 秒内没有完成启动健康确认。");
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs));
setProgress(99, "正在提交更新事务...");
if (!transaction.commit())
@@ -317,7 +396,7 @@ int main(int argc, char* argv[])
QFile::remove(healthFile);
QFile::remove(bootstrapPlanFile());
logic.reportResult(deviceId, fromVersion, targetVersion, true);
reportUpdateResult(true);
progress.setValue(100);
progress.close();
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
+528 -71
View File
@@ -5,65 +5,339 @@
<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; }
:root {
--bg:#f6f7fb;
--surface:#fff;
--ink:#172033;
--muted:#667085;
--line:#dfe5ee;
--field:#fdfefe;
--brand:#3157d5;
--brand-hover:#2447bc;
--danger:#d92d20;
--success:#067647;
--warning:#9a6700;
}
* { 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; }
html { background:var(--bg); }
body {
max-width:1280px;
margin:0 auto;
padding:0 24px 48px;
color:var(--ink);
background:var(--bg);
font:14px/1.45 Inter,"Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
h1 {
margin:0 -24px 22px;
padding:24px;
color:var(--ink);
background:var(--surface);
border-bottom:1px solid var(--line);
box-shadow:0 1px 2px rgba(16,24,40,.04);
font-size:26px;
line-height:1.2;
letter-spacing:0;
}
h1::before {
content:"MARSCO UPDATE SERVICE";
display:block;
margin-bottom:6px;
color:#5d6b82;
font-size:11px;
font-weight:700;
letter-spacing:.14em;
}
h1::after {
content:" · 软件发布与升级管理";
color:var(--muted);
font-size:14px;
font-weight:400;
}
h2 {
margin:0 0 16px;
padding-bottom:12px;
border-bottom:1px solid #edf0f4;
font-size:16px;
line-height:1.3;
letter-spacing:0;
}
.section {
margin-bottom:16px;
padding:18px;
overflow-x:auto;
background:var(--surface);
border:1px solid var(--line);
border-radius:8px;
box-shadow:0 1px 3px rgba(16,24,40,.06);
}
.row {
display:flex;
flex-wrap:wrap;
align-items:center;
gap:10px 12px;
margin-top:12px;
}
.section h2 + .row { margin-top:0; }
.row label {
flex:0 0 116px;
min-width:0;
margin:0;
color:#344054;
font-size:13px;
font-weight:650;
line-height:1.25;
}
.row label input[type="checkbox"] { margin:0 7px 0 0; }
.row input,
.row select {
flex:1 1 220px;
width:auto;
min-width:180px;
max-width:360px;
height:38px;
padding:8px 11px;
color:var(--ink);
background:var(--field);
border:1px solid #cfd6e2;
border-radius:6px;
outline:none;
transition:border-color .16s ease, box-shadow .16s ease, background .16s ease;
}
.row input[type="number"] { flex-basis:120px; max-width:160px; }
.row input[type="checkbox"] {
flex:0 0 auto;
width:16px;
min-width:16px;
height:16px;
padding:0;
}
.row input[type="file"] {
flex:1 1 420px;
max-width:none;
min-height:38px;
height:auto;
padding:7px;
background:#fafbfc;
}
.row input[type="file"]::file-selector-button {
margin-right:10px;
padding:7px 11px;
color:#344054;
background:#e9edf5;
border:0;
border-radius:6px;
cursor:pointer;
}
.row input:focus,
.row select:focus {
border-color:#7893e8;
background:#fff;
box-shadow:0 0 0 3px rgba(49,87,213,.12);
}
.row .wide,
.row input.wide {
flex:1 1 540px;
max-width:none;
}
.row button,
td button {
min-height:38px;
padding:8px 12px;
color:#344054;
background:#fff;
border:1px solid #cfd6e2;
border-radius:6px;
font-weight:650;
white-space:nowrap;
cursor:pointer;
transition:border-color .16s ease, box-shadow .16s ease, transform .16s ease, background .16s ease;
}
.row button {
flex:0 0 auto;
display:inline-flex;
align-items:center;
justify-content:center;
height:38px;
}
.action-row {
justify-content:flex-start;
padding-left:128px;
}
.checkbox-label {
flex:0 0 auto!important;
display:inline-flex;
align-items:center;
gap:7px;
min-width:96px!important;
color:#344054;
}
.checkbox-label input[type="checkbox"] { margin:0; }
.row button:hover,
td button:hover {
border-color:#98a2b3;
box-shadow:0 3px 9px rgba(16,24,40,.08);
transform:translateY(-1px);
}
#saveTokenBtn,
#addAppBtn,
#publishBtn,
#saveChannelBtn,
#savePolicyBtn,
#createLicenseBtn {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#saveTokenBtn:hover,
#addAppBtn:hover,
#publishBtn:hover,
#saveChannelBtn:hover,
#savePolicyBtn:hover,
#createLicenseBtn:hover { background:var(--brand-hover); }
#changeTokenBtn,
#confirmChangeTokenBtn {
color:var(--brand);
border-color:#b9c7ef;
background:#f7f9ff;
}
#confirmChangeTokenBtn {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#toggleTokenBtn { min-width:64px; }
button:disabled {
opacity:.55;
cursor:not-allowed;
box-shadow:none!important;
transform:none!important;
}
table {
width:100%;
min-width:720px;
margin-top:14px;
overflow:hidden;
border:1px solid var(--line);
border-radius:8px;
border-collapse:separate;
border-spacing:0;
background:#fff;
}
th,
td {
padding:11px 12px;
border:0;
border-bottom:1px solid #edf0f4;
text-align:left;
vertical-align:middle;
}
th {
color:#475467;
background:#f8f9fb;
font-size:12px;
font-weight:700;
letter-spacing:0;
white-space:nowrap;
}
td { color:#253047; }
td:last-child { white-space:nowrap; }
td button { margin:2px 4px 2px 0; }
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; }
td button[data-action="delete"] {
color:var(--danger);
border-color:#fecdca;
background:#fff7f6;
}
.badge {
display:inline-flex;
align-items:center;
min-height:22px;
padding:3px 8px;
color:#475467;
background:#f2f4f7;
border-radius:999px;
font-size:12px;
font-weight:700;
white-space:nowrap;
}
.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; }
.empty {
padding:28px 12px!important;
color:var(--muted);
text-align:center;
}
.small {
display:inline-flex;
align-items:center;
min-height:28px;
margin:0;
color:var(--muted);
font-size:13px;
line-height:1.35;
}
pre {
margin:14px 0 0;
min-height:105px;
max-height:320px;
padding:14px;
overflow:auto;
color:#d8e4ff;
background:#11182a;
border-radius:8px;
font:12px/1.65 "Cascadia Code",Consolas,monospace;
white-space:pre-wrap;
}
.token-change {
margin-top:14px;
padding:14px;
background:#f8faff;
border:1px solid #dce3f1;
border-radius:8px;
}
.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 {
position:fixed;
right:22px;
bottom:22px;
z-index:20;
max-width:380px;
padding:12px 15px;
color:#fff;
background:#1d2939;
border-radius:8px;
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}}
@media(max-width:820px) {
body { padding:0 12px 36px; }
h1 { margin:0 -12px 16px; padding:20px 12px; font-size:23px; }
h1::after { display:block; margin-top:4px; }
.section { padding:15px; margin-bottom:12px; }
.row { display:grid; grid-template-columns:1fr; align-items:stretch; }
.row label,
.row input,
.row select,
.row button,
.row .wide,
.row input.wide {
width:100%;
max-width:none;
min-width:0;
}
.row label { min-height:auto; }
.action-row { padding-left:0; }
.row input[type="checkbox"] { width:16px; min-width:16px; }
table { min-width:760px; }
.toast { right:12px; left:12px; max-width:none; }
}
</style>
</head>
<body><div id="toast" class="toast"></div>
@@ -77,9 +351,19 @@
<button id="saveTokenBtn" type="button">登录并保存</button>
<button id="changeTokenBtn" type="button">更改令牌</button>
</div>
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端只保存令牌哈希</div>
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验</div>
<div class="small">当前连接服务端:<strong id="apiBaseLabel"></strong></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 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="再次输入新令牌" />
</div>
<div class="row action-row">
<button id="confirmChangeTokenBtn" type="button">确认更改</button>
<button id="cancelChangeTokenBtn" type="button">取消</button>
</div>
</div>
</div>
@@ -99,6 +383,18 @@
</div>
</div>
<div class="section">
<h2>渠道管理</h2>
<div class="row">
<label for="channelCode">渠道代码</label><input id="channelCode" placeholder="例如 enterprise">
<label for="channelName">显示名称</label><input id="channelName" placeholder="例如 企业版">
<label for="channelOrder">排序</label><input id="channelOrder" type="number" value="100">
<label class="checkbox-label"><input id="channelEnabled" type="checkbox" checked>启用</label>
</div>
<div class="row action-row"><button id="saveChannelBtn" type="button">新增或保存</button><button id="refreshChannelsBtn" type="button">刷新渠道</button></div>
<table><thead><tr><th>代码</th><th>名称</th><th>状态</th><th>排序</th><th>操作</th></tr></thead><tbody id="channelRows"></tbody></table>
</div>
<div class="section">
<h2>发布新版本</h2>
<div class="row">
@@ -107,7 +403,8 @@
<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>
<select id="publishChannel"></select>
<label for="publishProtocol">客户端协议</label><input id="publishProtocol" type="number" min="1" value="3" />
</div>
<div class="row">
<label for="publishFiles">软件根目录</label>
@@ -131,6 +428,7 @@
<th>ID</th>
<th>版本</th>
<th>渠道</th>
<th>协议</th>
<th>最新</th>
<th>创建时间</th>
<th>操作</th>
@@ -143,7 +441,7 @@
<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 for="policyChannel">渠道</label><select id="policyChannel"></select>
<label><input id="policyForceUpdate" type="checkbox" /> 强制升级</label>
<label><input id="policyAllowRollback" type="checkbox" /> 允许降级</label>
<label><input id="policyOfflineAllowed" type="checkbox" checked /> 允许离线启动</label>
@@ -160,6 +458,25 @@
</div>
</div>
<div class="section">
<h2>License 授权</h2>
<div class="row">
<label for="licenseCustomer">客户</label><input id="licenseCustomer" placeholder="客户或项目名称">
<label for="licenseChannel">渠道</label><select id="licenseChannel"></select>
<label for="licenseMaxDevices">最大设备数</label><input id="licenseMaxDevices" type="number" min="1" value="1">
<label for="licenseValidUntil">有效期</label><input id="licenseValidUntil" value="2099-12-31T23:59:59Z">
</div>
<div class="row action-row"><button id="createLicenseBtn" type="button">创建授权</button><button id="refreshLicensesBtn" type="button">刷新授权</button></div>
<div class="row"><label for="createdLicenseKey">新授权密钥</label><input id="createdLicenseKey" class="wide" readonly placeholder="仅显示一次;创建后请立即复制到客户端 app_config.json 的 license_key"></div>
<table><thead><tr><th>License ID</th><th>客户</th><th>渠道</th><th>设备</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="licenseRows"></tbody></table>
</div>
<div class="section">
<h2>设备管理</h2>
<div class="row"><button id="refreshDevicesBtn" type="button">刷新设备</button><span class="small">显示当前应用登记的设备;禁用后客户端请求会立即被拒绝</span></div>
<table><thead><tr><th>设备 ID</th><th>状态</th><th>凭证序列</th><th>最近访问</th><th>IP</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table>
</div>
<div class="section">
<h2>升级日志</h2>
<div class="row">
@@ -179,14 +496,26 @@
</table>
</div>
<div class="section">
<h2>文件下载日志</h2><div class="row"><button id="refreshDownloadLogsBtn">刷新下载日志</button></div>
<table><thead><tr><th>设备</th><th>License</th><th>版本/渠道</th><th>文件</th><th>大小</th><th>结果</th><th>IP</th><th>时间</th></tr></thead><tbody id="downloadLogRows"></tbody></table>
</div>
<div class="section">
<h2>管理员审计日志</h2><div class="row"><button id="refreshAuditLogsBtn">刷新审计日志</button></div>
<table><thead><tr><th>管理员指纹</th><th>操作</th><th>结果</th><th>状态码</th><th>IP</th><th>时间</th></tr></thead><tbody id="auditLogRows"></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 apiBase = (location.protocol === 'http:' || location.protocol === 'https:')
? location.origin
: 'http://127.0.0.1:8000';
const adminTokenInput = document.getElementById('adminToken');
const apiBaseLabel = document.getElementById('apiBaseLabel');
const toggleTokenBtn = document.getElementById('toggleTokenBtn');
const tokenChangePanel = document.getElementById('tokenChangePanel');
const newAdminTokenInput = document.getElementById('newAdminToken');
@@ -198,16 +527,23 @@
const publishAppIdInput = document.getElementById('publishAppId');
const publishVersionInput = document.getElementById('publishVersion');
const publishChannelInput = document.getElementById('publishChannel');
const publishProtocolInput = document.getElementById('publishProtocol');
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');
const deviceRows = document.getElementById('deviceRows');
const licenseRows = document.getElementById('licenseRows');
const channelRows = document.getElementById('channelRows');
const downloadLogRows = document.getElementById('downloadLogRows');
const auditLogRows = document.getElementById('auditLogRows');
let currentToken = localStorage.getItem('admin_token') || '';
let apps = [];
let currentAppId = '';
let channels = [];
function log(message) {
if (typeof message === 'string') {
@@ -239,6 +575,7 @@
const url = apiBase + path;
const reqOptions = {
...options,
cache: 'no-store',
headers: { ...(options.headers || {}), ...getHeaders(options.contentType) },
};
delete reqOptions.contentType;
@@ -321,28 +658,43 @@
}
const chrBackslash = String.fromCharCode(92);
let releaseMainExecutable = 'MainApp.exe';
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');
const parts = path.split('/');
const blockedDirectory = parts.slice(0, -1).some(part => part === '.git' || part === '.vs' || part === 'cmakefiles' || part === 'debug' || part === 'update' || part === 'update_temp' || part.startsWith('build') || part.endsWith('_autogen'));
return !excludedFiles.has(path) && !blockedDirectory && !path.endsWith('.pdb') && !path.endsWith('.ilk') && !path.endsWith('.obj');
});
}
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'));
const mainName = releaseMainExecutable.toLowerCase();
if (!lowerPaths.includes(mainName)) {
const nestedMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (nestedMain) {
const expectedRoot = nestedMain.slice(0, -'/mainapp.exe'.length);
return '选择层级过高:MainApp.exe 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
const expectedRoot = nestedMain.slice(0, -(mainName.length + 1));
return '选择层级过高:' + releaseMainExecutable + ' 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
}
return '所选目录根级没有 MainApp.exe,不能作为软件发布根目录';
return '所选目录根级没有 ' + releaseMainExecutable + ',不能作为软件发布根目录';
}
const duplicateMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (duplicateMain) return '目录中存在嵌套的重复主程序 ' + duplicateMain + ',请选择干净的 Release 输出目录';
return '';
}
async function loadRuntimeConfig() {
try {
const data = await request('/admin/runtime-config');
releaseMainExecutable = data.release_main_executable || releaseMainExecutable;
} catch (err) {
log({ warning: '读取发布配置失败,将使用默认主程序名', details: err.message || err });
}
}
function renderSelectedDirectory() {
const allCount = publishFilesInput.files.length;
const entries = selectedUploadEntries();
@@ -356,19 +708,29 @@
publishFilePreview.textContent = paths.length ? paths.join('\n') + (entries.length > 30 ? '\n... 其余 ' + (entries.length - 30) + ' 个文件' : '') : '尚未选择发布目录';
}
function renderChannels() {
const enabled=channels.filter(c=>c.enabled); const selects=[publishChannelInput,document.getElementById('policyChannel'),document.getElementById('licenseChannel')];
selects.forEach(select=>{const previous=select.value;select.innerHTML='';enabled.forEach(c=>{const o=document.createElement('option');o.value=c.channel_code;o.textContent=`${c.channel_code}${c.display_name}`;select.appendChild(o);});if(enabled.some(c=>c.channel_code===previous))select.value=previous;});
channelRows.innerHTML=channels.length?'':'<tr><td colspan="5" class="empty">暂无渠道</td></tr>';channels.forEach(c=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${c.channel_code}</td><td>${c.display_name}</td><td><span class="badge ${c.enabled?'success':'fail'}">${c.enabled?'启用':'停用'}</span></td><td>${c.sort_order}</td><td><button data-channel-code="${c.channel_code}">编辑</button></td>`;channelRows.appendChild(tr);});
}
async function loadChannels(){const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){channels=[];renderChannels();return;}try{const data=await request(`/admin/channel/list?app_id=${encodeURIComponent(appId)}`);channels=data.list||[];renderChannels();}catch(err){log(err);notify('读取渠道失败',true);}}
async function saveChannel(){const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;}const body={app_id:appId,channel_code:document.getElementById('channelCode').value.trim(),display_name:document.getElementById('channelName').value.trim(),sort_order:Number(document.getElementById('channelOrder').value),enabled:document.getElementById('channelEnabled').checked};try{await request('/admin/channel/save',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});notify('渠道已保存');await loadChannels();}catch(err){log(err);notify('保存渠道失败',true);}}
async function publishVersion() {
const appId = publishAppIdInput.value.trim();
const version = publishVersionInput.value.trim();
const channel = publishChannelInput.value.trim() || 'stable';
const channel = publishChannelInput.value.trim();
const entries = selectedUploadEntries();
if (!appId) { log('请选择或填写 App ID'); return; }
if (!version) { log('请输入版本号'); return; }
if (!channel) { notify('当前应用没有已启用渠道', true); 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);
formData.append('client_protocol', String(Math.max(1, Number(publishProtocolInput.value) || 3)));
for (const item of entries) {
formData.append('files', item.file, item.file.name);
formData.append('relative_paths', item.path);
@@ -412,17 +774,20 @@
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>';
versionRows.innerHTML = rows.length ? '' : '<tr><td colspan="7" 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">P${item.client_protocol || 1}</span></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="set-protocol" data-id="${item.id}" data-protocol="${item.client_protocol || 1}">修改协议</button>
<button type="button" data-action="offline-package" data-id="${item.id}" data-version="${item.version}">下载 .upd</button>
<button type="button" data-action="delete" data-id="${item.id}">删除</button>
</td>
`;
@@ -434,6 +799,11 @@
}
}
async function downloadOfflinePackage(versionId, version) {
notify('正在生成离线包,请保持页面打开');
try { const resp=await fetch(`${apiBase}/admin/version/offline-package`,{method:'POST',headers:getHeaders('application/json'),body:JSON.stringify({version_id:Number(versionId)})}); if(!resp.ok)throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); const blob=await resp.blob(); const url=URL.createObjectURL(blob); const a=document.createElement('a');a.href=url;a.download=`${currentAppId}_${version}.upd`;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);notify('离线包已生成'); } catch(err){log(err);notify('离线包生成失败',true);}
}
async function setLatest(versionId) {
log(`正在设置版本 ${versionId} 为最新...`);
try {
@@ -449,6 +819,18 @@
}
}
async function setVersionProtocol(versionId, currentProtocol) {
const raw = window.prompt('请输入该版本实际支持的客户端协议号', String(currentProtocol || 1));
if (raw === null) return;
const protocol = Number(raw);
if (!Number.isInteger(protocol) || protocol < 1) { notify('协议号必须是正整数', true); return; }
try {
const body = await request('/admin/version/set-protocol', {method:'POST', contentType:'application/json',
body:JSON.stringify({version_id:Number(versionId), client_protocol:protocol})});
log(body); notify('版本协议已更新为 P' + protocol); await loadVersions();
} catch (err) { log(err); notify('修改协议失败', true); }
}
async function deleteVersion(versionId) {
if (!window.confirm(`确定删除版本记录 #${versionId} 及其云端文件吗?此操作不可撤销。`)) return;
log(`正在删除版本 ${versionId} ...`);
@@ -502,6 +884,36 @@
} catch (err) { log(err); notify('保存策略失败', true); }
}
async function loadLicenses() {
const appId=currentAppId||publishAppIdInput.value.trim(); if(!appId){licenseRows.innerHTML='<tr><td colspan="7" class="empty">请先选择应用</td></tr>';return;}
try { const data=await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`); const rows=data.list||[]; licenseRows.innerHTML=rows.length?'':'<tr><td colspan="7" class="empty">暂无授权</td></tr>'; rows.forEach(item=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${item.license_id}</td><td>${item.customer_name}</td><td>${item.channel_code}</td><td>${item.used_devices} / ${item.max_devices}</td><td>${item.valid_until}</td><td><span class="badge ${item.status==='active'?'success':'fail'}">${item.status==='active'?'有效':'已禁用'}</span></td><td><button data-license-id="${item.license_id}" data-status="${item.status}">${item.status==='active'?'禁用':'启用'}</button></td>`;licenseRows.appendChild(tr);}); } catch(err){log(err);notify('读取授权失败',true);}
}
async function createLicense(){
const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;} const body={app_id:appId,customer_name:document.getElementById('licenseCustomer').value.trim(),channel:document.getElementById('licenseChannel').value,max_devices:Number(document.getElementById('licenseMaxDevices').value),valid_until:document.getElementById('licenseValidUntil').value.trim()};
try{const result=await request('/admin/license/create',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});document.getElementById('createdLicenseKey').value=result.license_key;log(result);notify('授权已创建,请立即复制密钥');await loadLicenses();}catch(err){log(err);notify('创建授权失败',true);}
}
async function toggleLicense(id,status){try{await request('/admin/license/set-status',{method:'POST',contentType:'application/json',body:JSON.stringify({license_id:id,status:status==='active'?'disabled':'active'})});await loadLicenses();notify('授权状态已更新');}catch(err){log(err);notify('修改授权失败',true);}}
async function loadDevices() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) { deviceRows.innerHTML = '<tr><td colspan="6" class="empty">请先选择应用</td></tr>'; return; }
try {
const data = await request(`/admin/device/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
deviceRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">暂无登记设备</td></tr>';
rows.forEach(item => { const tr=document.createElement('tr'); tr.innerHTML=`<td>${item.device_id}</td><td><span class="badge ${item.disabled?'fail':'success'}">${item.disabled?'已禁用':'正常'}</span></td><td>${item.credential_seq}</td><td>${item.last_seen_at||''}</td><td>${item.last_ip||''}</td><td><button data-device-id="${item.device_id}" data-disabled="${item.disabled}">${item.disabled?'恢复':'禁用'}</button></td>`; deviceRows.appendChild(tr); });
} catch (err) { log(err); notify('读取设备列表失败', true); }
}
async function toggleDevice(deviceId, disabled) {
let reason=''; if (!disabled) { reason=window.prompt('请输入禁用原因', '管理员禁用') || ''; if (!reason) return; }
try { const result=await request('/admin/device/set-disabled',{method:'POST',contentType:'application/json',body:JSON.stringify({device_id:deviceId,disabled:!disabled,reason})}); log(result); notify(result.msg); await loadDevices(); } catch(err) { log(err); notify('设备状态修改失败',true); }
}
function formatLogBytes(bytes){const n=Number(bytes)||0;return n>=1048576?(n/1048576).toFixed(2)+' MB':n>=1024?(n/1024).toFixed(1)+' KB':n+' B';}
async function loadDownloadLogs(){const appId=currentAppId||publishAppIdInput.value.trim();try{const data=await request(`/admin/download-log/list?app_id=${encodeURIComponent(appId)}&limit=300`);const rows=data.list||[];downloadLogRows.innerHTML=rows.length?'':'<tr><td colspan="8" class="empty">暂无下载日志</td></tr>';rows.forEach(x=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${x.device_id}</td><td>${x.license_id}</td><td>${x.version} / ${x.channel_code}</td><td>${x.file_path}</td><td>${formatLogBytes(x.file_size)}</td><td><span class="badge ${x.result==='success'?'success':x.result==='fail'?'fail':''}">${x.result}</span></td><td>${x.ip||''}</td><td>${x.created_at||''}</td>`;downloadLogRows.appendChild(tr);});}catch(err){log(err);notify('读取下载日志失败',true);}}
async function loadAuditLogs(){try{const data=await request('/admin/audit-log/list?limit=300');const rows=data.list||[];auditLogRows.innerHTML=rows.length?'':'<tr><td colspan="6" class="empty">暂无审计日志</td></tr>';rows.forEach(x=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${x.actor_hash}</td><td>${x.method} ${x.path}</td><td><span class="badge ${x.result==='success'?'success':'fail'}">${x.result}</span></td><td>${x.status_code}</td><td>${x.ip||''}</td><td>${x.created_at||''}</td>`;auditLogRows.appendChild(tr);});}catch(err){log(err);notify('读取审计日志失败',true);}}
async function loadReports() {
log('正在加载升级日志...');
try {
@@ -539,12 +951,17 @@
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
const token = adminTokenInput.value.trim();
if (!token) { notify('令牌不能为空', true); return; }
const previousToken = currentToken;
currentToken = token;
localStorage.setItem('admin_token', currentToken);
try {
await loadApps();
await request('/admin/auth/check');
localStorage.setItem('admin_token', currentToken);
await loadConsoleData();
notify('令牌验证成功并已保存');
} catch (err) {
currentToken = previousToken;
updateTokenInput();
log(err);
notify('令牌验证失败', true);
}
});
@@ -561,10 +978,13 @@
});
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
const currentInputToken = adminTokenInput.value.trim();
const nextToken = newAdminTokenInput.value.trim();
const confirmation = confirmAdminTokenInput.value.trim();
if (!currentInputToken) { notify('请先输入当前管理员令牌', true); return; }
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
currentToken = currentInputToken;
try {
const result = await request('/admin/token/change', { method: 'POST', contentType: 'application/json', body: JSON.stringify({ new_token: nextToken }) });
currentToken = nextToken;
@@ -574,19 +994,30 @@
newAdminTokenInput.value = '';
confirmAdminTokenInput.value = '';
log(result);
notify('管理员令牌已更改');
notify('管理员令牌已在当前服务进程生效,请同步修改 .env');
} catch (err) {
log(err);
notify('更改令牌失败,请确认当前令牌正确', true);
const detail = err && err.body && err.body.detail ? err.body.detail : '请确认当前令牌正确';
notify('更改令牌失败:' + detail, true);
}
});
document.getElementById('refreshAppsBtn').addEventListener('click', loadApps);
document.getElementById('refreshChannelsBtn').addEventListener('click', loadChannels);
document.getElementById('saveChannelBtn').addEventListener('click', saveChannel);
channelRows.addEventListener('click',event=>{const b=event.target.closest('button[data-channel-code]');if(!b)return;const c=channels.find(x=>x.channel_code===b.dataset.channelCode);if(!c)return;document.getElementById('channelCode').value=c.channel_code;document.getElementById('channelName').value=c.display_name;document.getElementById('channelOrder').value=c.sort_order;document.getElementById('channelEnabled').checked=c.enabled;});
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('refreshDownloadLogsBtn').addEventListener('click', loadDownloadLogs);
document.getElementById('refreshAuditLogsBtn').addEventListener('click', loadAuditLogs);
document.getElementById('refreshDevicesBtn').addEventListener('click', loadDevices);
document.getElementById('refreshLicensesBtn').addEventListener('click', loadLicenses);
document.getElementById('createLicenseBtn').addEventListener('click', createLicense);
licenseRows.addEventListener('click', event=>{const b=event.target.closest('button[data-license-id]');if(b)toggleLicense(b.dataset.licenseId,b.dataset.status);});
deviceRows.addEventListener('click', event => { const b=event.target.closest('button[data-device-id]'); if(b) toggleDevice(b.dataset.deviceId, b.dataset.disabled === 'true'); });
document.getElementById('loadPolicyBtn').addEventListener('click', loadPolicy);
document.getElementById('savePolicyBtn').addEventListener('click', savePolicy);
document.getElementById('policyChannel').addEventListener('change', loadPolicy);
@@ -597,7 +1028,7 @@
publishAppIdInput.value = currentAppId;
}
currentAppLabel.textContent = currentAppId || '未选择';
if (currentAppId) loadPolicy();
if (currentAppId) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
});
versionRows.addEventListener('click', async (event) => {
@@ -607,15 +1038,41 @@
const id = button.dataset.id;
if (action === 'set-latest') {
await setLatest(id);
} else if (action === 'set-protocol') {
await setVersionProtocol(id, button.dataset.protocol);
} else if (action === 'offline-package') {
await downloadOfflinePackage(id, button.dataset.version);
} else if (action === 'delete') {
await deleteVersion(id);
}
});
window.addEventListener('load', async () => {
updateTokenInput();
async function loadConsoleData() {
await loadRuntimeConfig();
renderSelectedDirectory();
await loadApps();
await loadReports();
await loadLicenses();
await loadDevices();
await loadDownloadLogs();
await loadAuditLogs();
}
function renderLoggedOutState() {
versionRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示版本列表</td></tr>';
reportRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示升级日志</td></tr>';
downloadLogRows.innerHTML = '<tr><td colspan="8" class="empty">登录后显示下载日志</td></tr>';
auditLogRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示审计日志</td></tr>';
deviceRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示设备</td></tr>';
licenseRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示授权</td></tr>';
channelRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示渠道</td></tr>';
log('请输入管理员令牌后点击“登录并保存”。页面不会在打开时自动读取服务端日志。当前连接服务端:' + apiBase);
}
window.addEventListener('load', () => {
apiBaseLabel.textContent = apiBase;
updateTokenInput();
renderLoggedOutState();
});
</script>
</body>
+16 -9
View File
@@ -1,14 +1,21 @@
{
"app_id": "your_app_id",
"app_name": "Your Application",
"app_id": "simcae",
"app_name": "SimCAE",
"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,
"client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000",
"temp_folder": "update_temp",
"device_id": ""
"device_id": "",
"main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000",
"platform": "windows",
"arch": "x64"
}
-6
View File
@@ -1,6 +0,0 @@
{
"max_policy_seq": 0,
"last_success_run_at": "",
"last_online_verified_at": "",
"last_success_version": ""
}
-1
View File
@@ -1 +0,0 @@
{"disabled_versions":[],"force_update":false,"offline_allowed":true,"policy_seq":1,"valid_until":"2099-12-31T23:59:59Z","signature":"d6982cb02d9a0c6fb6b7176961a7736dbee0decfbd5e422bd189521c72664205"}
+41
View File
@@ -0,0 +1,41 @@
param(
[Parameter(Mandatory=$true)]
[string]$SdkRoot,
[string]$ReleaseDir = (Get-Location).Path,
[switch]$OverwriteConfig
)
$ErrorActionPreference = "Stop"
$sdk = (Resolve-Path $SdkRoot).Path
$release = (Resolve-Path $ReleaseDir).Path
$binDir = Join-Path $sdk "bin"
$configDir = Join-Path $sdk "config"
$appConfig = Join-Path $configDir "app_config.json"
$publicKey = Join-Path $configDir "manifest_public_key.pem"
foreach ($path in @($binDir, $appConfig, $publicKey)) {
if (-not (Test-Path $path)) {
throw "SDK file is missing: $path"
}
}
Copy-Item (Join-Path $binDir "*") $release -Recurse -Force
$targetConfigDir = Join-Path $release "config"
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
$targetAppConfig = Join-Path $targetConfigDir "app_config.json"
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
Copy-Item $appConfig $targetAppConfig -Force
} else {
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
}
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force
Write-Host "SDK files installed to: $release"
Write-Host "Next: edit config/app_config.json, then start Launcher.exe."
+87
View File
@@ -0,0 +1,87 @@
param(
[string]$SourceDir = "$PSScriptRoot/out/bin",
[Parameter(Mandatory = $true)]
[string]$ConfigFile,
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClient",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip"
)
$ErrorActionPreference = "Stop"
$source = (Resolve-Path $SourceDir).Path
$config = (Resolve-Path $ConfigFile).Path
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
$requiredFields = @(
"app_id", "channel", "api_base_url", "current_version",
"client_token", "launch_token", "license_key",
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
)
foreach ($field in $requiredFields) {
if (-not $settings.$field) {
throw "Config file is missing required field: $field"
}
}
$requiredFiles = @(
$settings.main_executable,
$settings.launcher_executable,
$settings.updater_executable,
$settings.bootstrap_executable
)
foreach ($name in $requiredFiles) {
if (-not (Test-Path (Join-Path $source $name))) {
throw "Source directory is missing required file: $name"
}
}
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
$_.Extension -in @('.pdb', '.ilk')
}
if ($debugArtifacts) {
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
}
$nestedMain = Get-ChildItem $source -Recurse -File -Filter $settings.main_executable | Where-Object {
$_.DirectoryName -ne $source
} | Select-Object -First 1
if ($nestedMain) {
throw "Source directory contains a nested duplicate main executable. Use a clean Release root directory: $($nestedMain.FullName)"
}
$manifestName = "manifest_$($settings.current_version).json"
$sourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
if (-not (Test-Path $sourceManifest)) {
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
}
if (Test-Path $OutputDir) {
Remove-Item $OutputDir -Recurse -Force
}
New-Item $OutputDir -ItemType Directory -Force | Out-Null
Get-ChildItem $source -Force | Where-Object {
$_.Name -notin @("update", "update_temp")
} | Copy-Item -Destination $OutputDir -Recurse -Force
$outputConfigDir = Join-Path $OutputDir "config"
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
Copy-Item $config (Join-Path $outputConfigDir "app_config.json") -Force
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
$runtimeFile = Join-Path $outputConfigDir $_
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force }
}
$manifestDir = Join-Path $OutputDir "update/manifest_cache"
New-Item $manifestDir -ItemType Directory -Force | Out-Null
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
$zipParent = Split-Path $ZipFile -Parent
New-Item $zipParent -ItemType Directory -Force | Out-Null
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
Write-Host "Package directory: $OutputDir"
Write-Host "ZIP file: $ZipFile"
+86
View File
@@ -0,0 +1,86 @@
param(
[string]$SourceDir = "$PSScriptRoot/out/bin",
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
[string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
[switch]$IncludeDemoMainApp
)
$ErrorActionPreference = "Stop"
$source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
foreach ($name in $requiredFiles) {
$path = Join-Path $source $name
if (-not (Test-Path $path)) {
throw "SDK source directory is missing required file: $path"
}
}
$publicKeyCandidates = @(
(Join-Path $source "config/manifest_public_key.pem"),
(Join-Path $source "manifest_public_key.pem"),
(Join-Path $PSScriptRoot "config/manifest_public_key.pem")
)
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) {
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key."
}
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
$_.Extension -in @('.pdb', '.ilk')
}
if ($debugArtifacts) {
throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)"
}
if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force }
New-Item $OutputDir -ItemType Directory -Force | Out-Null
$binDir = Join-Path $OutputDir "bin"
$configDir = Join-Path $OutputDir "config"
$scriptsDir = Join-Path $OutputDir "scripts"
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
$excludedTopLevel = @("config", "update", "update_temp")
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
Get-ChildItem $source -Force | Where-Object {
$_.Name -notin $excludedTopLevel
} | Copy-Item -Destination $binDir -Recurse -Force
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object {
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*"
} | Sort-Object Name | Select-Object -First 1
if (-not $wordGuideSource) {
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory."
}
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force
Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force
@{
sdk_version = $SdkVersion
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
sdk_type = "external-updater-runtime"
required_entry = "Launcher.exe"
contains_demo_main_app = [bool]$IncludeDemoMainApp
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
$zipParent = Split-Path $ZipFile -Parent
New-Item $zipParent -ItemType Directory -Force | Out-Null
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
Write-Host "SDK directory: $OutputDir"
Write-Host "SDK zip: $ZipFile"
Write-Host "SDK version: $SdkVersion"
Binary file not shown.
-538
View File
@@ -1,538 +0,0 @@
软件自动升级与版本控制系统——项目进度
更新时间: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 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。