新增了客户端打包成sdk的功能和服务端docker镜像打包的功能,并修复了一些问题
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
**
|
||||||
|
!server/Dockerfile
|
||||||
|
!server/requirements.txt
|
||||||
|
!server/main.py
|
||||||
|
!server/db.py
|
||||||
|
!server/minio_tool.py
|
||||||
|
!server/tables.sql
|
||||||
|
!client/admin.html
|
||||||
+45
-20
@@ -16,23 +16,15 @@ Desktop.ini
|
|||||||
*.bak
|
*.bak
|
||||||
*.swp
|
*.swp
|
||||||
*.log
|
*.log
|
||||||
|
*~
|
||||||
|
|
||||||
# Client: CMake / Visual Studio build outputs
|
# Archive/package outputs
|
||||||
client/out/
|
*.zip
|
||||||
client/build/
|
*.tar
|
||||||
client/build-*/
|
*.tar.gz
|
||||||
client/cmake-build-*/
|
*.tgz
|
||||||
client/.cmake/
|
*.7z
|
||||||
client/CMakeSettings.json
|
*.rar
|
||||||
client/CMakeUserPresets.json
|
|
||||||
client/Testing/
|
|
||||||
|
|
||||||
# Client: local runtime state and credentials
|
|
||||||
client/config/app_config.json
|
|
||||||
client/config/client_identity.dat
|
|
||||||
client/config/*.local.json
|
|
||||||
client/update/
|
|
||||||
client/update_temp/
|
|
||||||
|
|
||||||
# C/C++ generated artifacts outside build directories
|
# C/C++ generated artifacts outside build directories
|
||||||
*.obj
|
*.obj
|
||||||
@@ -47,9 +39,32 @@ client/update_temp/
|
|||||||
*.dll
|
*.dll
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
# Keep source-controlled PEM public keys; never commit private keys
|
# Client: CMake / Visual Studio build outputs
|
||||||
server/keys/*private*.pem
|
client/out/
|
||||||
server/keys/*private*.key
|
client/build/
|
||||||
|
client/build-*/
|
||||||
|
client/cmake-build-*/
|
||||||
|
client/.cmake/
|
||||||
|
client/CMakeFiles/
|
||||||
|
client/CMakeCache.txt
|
||||||
|
client/CMakeSettings.json
|
||||||
|
client/CMakeUserPresets.json
|
||||||
|
client/Testing/
|
||||||
|
|
||||||
|
# Client: third-party/business binary drops and generated packages
|
||||||
|
client/App/
|
||||||
|
client/dist/
|
||||||
|
|
||||||
|
# Client: local runtime state and credentials
|
||||||
|
client/config/app_config.json
|
||||||
|
client/config/client_identity.dat
|
||||||
|
client/config/local_state.json
|
||||||
|
client/config/version_policy.dat
|
||||||
|
client/config/*.local.json
|
||||||
|
client/config/*private*.pem
|
||||||
|
client/config/*private*.key
|
||||||
|
client/update/
|
||||||
|
client/update_temp/
|
||||||
|
|
||||||
# Server: Python environments and caches
|
# Server: Python environments and caches
|
||||||
server/venv/
|
server/venv/
|
||||||
@@ -66,17 +81,27 @@ server/htmlcov/
|
|||||||
server/.env
|
server/.env
|
||||||
server/.env.*
|
server/.env.*
|
||||||
!server/.env.example
|
!server/.env.example
|
||||||
|
!server/.env.docker.example
|
||||||
server/admin_token.sha256
|
server/admin_token.sha256
|
||||||
|
server/keys/*private*.pem
|
||||||
|
server/keys/*private*.key
|
||||||
|
|
||||||
# Server: runtime databases, uploads and object storage
|
# Server: runtime databases, uploads, object storage and generated packages
|
||||||
server/*.db
|
server/*.db
|
||||||
server/*.db-journal
|
server/*.db-journal
|
||||||
server/*.db-wal
|
server/*.db-wal
|
||||||
server/*.db-shm
|
server/*.db-shm
|
||||||
server/local_uploads/
|
server/local_uploads/
|
||||||
|
server/upload_spool/
|
||||||
|
server/crash_storage/
|
||||||
server/minio_data/
|
server/minio_data/
|
||||||
|
server/runtime/
|
||||||
|
server/dist/
|
||||||
server/minio
|
server/minio
|
||||||
|
|
||||||
# Server: runtime files
|
# Server: runtime files
|
||||||
server/*.pid
|
server/*.pid
|
||||||
server/*.log
|
server/*.log
|
||||||
|
|
||||||
|
# Docker/local generated files
|
||||||
|
.dockerignore.local
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
project(Common LANGUAGES C CXX)
|
project(Common LANGUAGES C CXX)
|
||||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
|
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
|
||||||
|
|
||||||
set(SRC
|
set(SRC
|
||||||
HttpHelper.h
|
HttpHelper.h
|
||||||
HttpHelper.cpp
|
HttpHelper.cpp
|
||||||
FileHelper.h
|
FileHelper.h
|
||||||
FileHelper.cpp
|
FileHelper.cpp
|
||||||
ConfigHelper.h
|
ConfigHelper.h
|
||||||
ConfigHelper.cpp
|
ConfigHelper.cpp
|
||||||
PolicyHelper.h
|
PolicyHelper.h
|
||||||
PolicyHelper.cpp
|
PolicyHelper.cpp
|
||||||
LocalStateHelper.h
|
LocalStateHelper.h
|
||||||
LocalStateHelper.cpp
|
LocalStateHelper.cpp
|
||||||
)
|
TicketHelper.h
|
||||||
add_library(Common STATIC ${SRC})
|
TicketHelper.cpp
|
||||||
|
IntegrityHelper.h
|
||||||
# Common编译自身需要OpenSSL头文件
|
IntegrityHelper.cpp
|
||||||
target_include_directories(Common
|
DeviceIdentityHelper.h
|
||||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
DeviceIdentityHelper.cpp
|
||||||
PRIVATE ${OPENSSL_INC}
|
)
|
||||||
)
|
add_library(Common STATIC ${SRC})
|
||||||
|
|
||||||
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
|
# Common编译自身需要OpenSSL头文件
|
||||||
target_link_directories(Common INTERFACE
|
target_include_directories(Common
|
||||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
|
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
|
PRIVATE ${OPENSSL_INC}
|
||||||
)
|
)
|
||||||
target_link_libraries(Common
|
|
||||||
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
|
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
|
||||||
INTERFACE libssl.lib libcrypto.lib
|
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
|
||||||
)
|
)
|
||||||
@@ -95,12 +95,19 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
|
|||||||
copyText("App", "app_name", "Marsco Demo App");
|
copyText("App", "app_name", "Marsco Demo App");
|
||||||
copyText("App", "channel", "stable");
|
copyText("App", "channel", "stable");
|
||||||
copyText("App", "current_version", "1.0.0");
|
copyText("App", "current_version", "1.0.0");
|
||||||
|
copyText("App", "client_protocol", "3");
|
||||||
copyText("App", "launch_token");
|
copyText("App", "launch_token");
|
||||||
|
copyText("License", "license_key");
|
||||||
copyText("Server", "api_base_url");
|
copyText("Server", "api_base_url");
|
||||||
copyText("Server", "client_token");
|
copyText("Server", "client_token");
|
||||||
copyText("Update", "request_timeout_ms", "5000");
|
copyText("Update", "request_timeout_ms", "5000");
|
||||||
copyText("Update", "temp_folder", "update_temp");
|
copyText("Update", "temp_folder", "update_temp");
|
||||||
copyText("Update", "device_id");
|
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("platform", "windows");
|
||||||
config.insert("arch", "x64");
|
config.insert("arch", "x64");
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,49 +1,53 @@
|
|||||||
#include "HttpHelper.h"
|
#include "HttpHelper.h"
|
||||||
#include <QNetworkProxy>
|
#include <QNetworkProxy>
|
||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
|
#include <QFile>
|
||||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
#include <QDir>
|
||||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
#include <QApplication>
|
||||||
{
|
|
||||||
QNetworkAccessManager* manager = new QNetworkAccessManager();
|
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||||
manager->setProxy(QNetworkProxy::NoProxy);
|
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||||
|
{
|
||||||
QNetworkRequest req(url);
|
QNetworkAccessManager* manager = new QNetworkAccessManager();
|
||||||
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
manager->setProxy(QNetworkProxy::NoProxy);
|
||||||
// Add auth token header
|
|
||||||
QString token = ConfigHelper::instance().getValue("Server", "client_token");
|
QNetworkRequest req(url);
|
||||||
req.setRawHeader("X-Client-Token", token.toUtf8());
|
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||||
|
// Add auth token header
|
||||||
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
|
QString token = ConfigHelper::instance().getValue("Server", "client_token");
|
||||||
qDebug() << "=== POST Request ===";
|
req.setRawHeader("X-Client-Token", token.toUtf8());
|
||||||
qDebug() << "Url:" << url;
|
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
|
||||||
qDebug() << "Body:" << data;
|
if (identity.open(QIODevice::ReadOnly))
|
||||||
|
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
|
||||||
QNetworkReply* reply = manager->post(req, data);
|
|
||||||
QEventLoop loop;
|
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
|
||||||
|
qDebug() << "=== POST Request ===";
|
||||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
qDebug() << "Url:" << url;
|
||||||
loop.exec();
|
qDebug() << "Body:" << data;
|
||||||
|
|
||||||
int retCode = 0;
|
QNetworkReply* reply = manager->post(req, data);
|
||||||
QJsonObject retObj;
|
QEventLoop loop;
|
||||||
|
|
||||||
if (reply->error() != QNetworkReply::NoError)
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
{
|
loop.exec();
|
||||||
qDebug() << "Network error code:" << reply->error();
|
|
||||||
qDebug() << "Error detail:" << reply->errorString();
|
int retCode = 0;
|
||||||
}
|
QJsonObject retObj;
|
||||||
else
|
|
||||||
{
|
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
const QByteArray respData = reply->readAll();
|
||||||
QByteArray respData = reply->readAll();
|
if (!respData.isEmpty()) {
|
||||||
qDebug() << "Server raw response:" << respData;
|
qDebug() << "Server raw response:" << respData;
|
||||||
QJsonDocument doc = QJsonDocument::fromJson(respData);
|
retObj = QJsonDocument::fromJson(respData).object();
|
||||||
retObj = doc.object();
|
}
|
||||||
}
|
if (reply->error() != QNetworkReply::NoError)
|
||||||
|
{
|
||||||
callback(retCode, retObj);
|
qDebug() << "Network error code:" << reply->error();
|
||||||
|
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
|
||||||
reply->deleteLater();
|
}
|
||||||
manager->deleteLater();
|
|
||||||
|
callback(retCode, retObj);
|
||||||
|
|
||||||
|
reply->deleteLater();
|
||||||
|
manager->deleteLater();
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); }
|
||||||
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").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::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
|
||||||
bool PolicyHelper::isExpired() const {
|
bool PolicyHelper::isExpired() const {
|
||||||
if (!isValid()) return true;
|
if (!isValid()) return true;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public:
|
|||||||
bool isVersionAllowed(const QString& currentVersion) const;
|
bool isVersionAllowed(const QString& currentVersion) const;
|
||||||
bool allowRun() const;
|
bool allowRun() const;
|
||||||
bool forceUpdate() const;
|
bool forceUpdate() const;
|
||||||
|
bool allowRollback() const;
|
||||||
bool isOfflineAllowed() const;
|
bool isOfflineAllowed() const;
|
||||||
bool isExpired() const;
|
bool isExpired() const;
|
||||||
qint64 policySeq() const;
|
qint64 policySeq() const;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -40,7 +40,7 @@ if(WIN32)
|
|||||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||||
add_custom_command(TARGET Launcher POST_BUILD
|
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"
|
COMMENT "自动部署Qt依赖dll"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ void UpdateLogic::checkUpdate()
|
|||||||
body["app_id"] = m_appId;
|
body["app_id"] = m_appId;
|
||||||
body["current_version"] = m_curVer;
|
body["current_version"] = m_curVer;
|
||||||
body["channel"] = m_channel;
|
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)
|
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";
|
QString url = m_serverAddr + "/api/v1/update/report";
|
||||||
QJsonObject body;
|
QJsonObject body;
|
||||||
|
body["app_id"] = m_appId;
|
||||||
body["device_id"] = deviceId;
|
body["device_id"] = deviceId;
|
||||||
body["from_version"] = fromVer;
|
body["from_version"] = fromVer;
|
||||||
body["to_version"] = toVer;
|
body["to_version"] = toVer;
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
#include "../Common/ConfigHelper.h"
|
#include "../Common/ConfigHelper.h"
|
||||||
#include "../Common/PolicyHelper.h"
|
#include "../Common/PolicyHelper.h"
|
||||||
#include "../Common/LocalStateHelper.h"
|
#include "../Common/LocalStateHelper.h"
|
||||||
|
#include "../Common/TicketHelper.h"
|
||||||
|
#include "../Common/DeviceIdentityHelper.h"
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileDialog>
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
@@ -26,6 +30,18 @@ int main(int argc, char* argv[])
|
|||||||
progress.show();
|
progress.show();
|
||||||
QApplication::processEvents();
|
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;
|
UpdateLogic logic;
|
||||||
logic.checkUpdate();
|
logic.checkUpdate();
|
||||||
|
|
||||||
@@ -37,13 +53,49 @@ int main(int argc, char* argv[])
|
|||||||
const QString appId = logic.getAppId();
|
const QString appId = logic.getAppId();
|
||||||
const QString channel = logic.getChannel();
|
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 launchToken = config.getValue("App", "launch_token");
|
||||||
const QString currentVersion = config.getValue("App", "current_version");
|
const QString currentVersion = config.getValue("App", "current_version");
|
||||||
const QString appDir = QApplication::applicationDirPath();
|
const QString appDir = QApplication::applicationDirPath();
|
||||||
const QString mainAppPath = appDir + "/MainApp.exe";
|
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||||
const QString updaterPath = appDir + "/Updater.exe";
|
const QString value = config.getValue("Runtime", key).trimmed();
|
||||||
const QStringList mainArgs{QString("--launcher-token=%1").arg(launchToken)};
|
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("正在验证本地运行策略...");
|
progress.setLabelText("正在验证本地运行策略...");
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
@@ -108,7 +160,7 @@ int main(int argc, char* argv[])
|
|||||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
|
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
|
||||||
}
|
}
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
if (!QProcess::startDetached(mainAppPath, mainArgs)) {
|
if (!startMainApp()) {
|
||||||
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -123,6 +175,16 @@ int main(int argc, char* argv[])
|
|||||||
return 0;
|
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())
|
if (!networkOk && !policy.isOfflineAllowed())
|
||||||
{
|
{
|
||||||
progress.close();
|
progress.close();
|
||||||
@@ -132,7 +194,7 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
|
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
if (!QProcess::startDetached(mainAppPath, mainArgs))
|
if (!startMainApp())
|
||||||
{
|
{
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
||||||
|
|||||||
@@ -16,6 +16,6 @@ if(WIN32)
|
|||||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||||
add_custom_command(TARGET MainApp POST_BUILD
|
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
@@ -10,6 +10,9 @@
|
|||||||
#include "../Common/ConfigHelper.h"
|
#include "../Common/ConfigHelper.h"
|
||||||
#include "../Common/PolicyHelper.h"
|
#include "../Common/PolicyHelper.h"
|
||||||
#include "../Common/LocalStateHelper.h"
|
#include "../Common/LocalStateHelper.h"
|
||||||
|
#include "../Common/TicketHelper.h"
|
||||||
|
#include "../Common/IntegrityHelper.h"
|
||||||
|
#include "../Common/DeviceIdentityHelper.h"
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
@@ -20,35 +23,40 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
qDebug() << Qt::endl << "entered main app" << Qt::endl;
|
qDebug() << Qt::endl << "entered main app" << Qt::endl;
|
||||||
|
|
||||||
const QString validToken = ConfigHelper::instance().getValue("App", "launch_token");
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
bool pass = false;
|
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
|
||||||
|
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
|
||||||
|
QString ticketFilePath;
|
||||||
QString healthFilePath;
|
QString healthFilePath;
|
||||||
|
for (int i = 1; i < argc; ++i)
|
||||||
for (int i = 0; i < argc; ++i)
|
|
||||||
{
|
{
|
||||||
QString arg(argv[i]);
|
const QString arg(argv[i]);
|
||||||
if (arg.startsWith("--launcher-token="))
|
if (arg.startsWith("--ticket-file="))
|
||||||
{
|
ticketFilePath = arg.mid(QString("--ticket-file=").size());
|
||||||
QString tk = arg.split("=").last();
|
|
||||||
if (tk == validToken)
|
|
||||||
{
|
|
||||||
pass = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (arg.startsWith("--health-file="))
|
else if (arg.startsWith("--health-file="))
|
||||||
{
|
|
||||||
healthFilePath = arg.mid(QString("--health-file=").size());
|
healthFilePath = arg.mid(QString("--health-file=").size());
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ticketError;
|
||||||
if (!pass)
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString appDir = QApplication::applicationDirPath();
|
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);
|
PolicyHelper policy(appDir);
|
||||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
||||||
{
|
{
|
||||||
@@ -81,6 +89,17 @@ int main(int argc, char* argv[])
|
|||||||
return -1;
|
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;
|
MainWindow w;
|
||||||
w.show();
|
w.show();
|
||||||
|
|
||||||
@@ -107,4 +126,4 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
qDebug() << "Main program MainApp is running normally";
|
qDebug() << "Main program MainApp is running normally";
|
||||||
return a.exec();
|
return a.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -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 的发布源目录。
|
||||||
|
|||||||
@@ -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.
@@ -1,9 +1,9 @@
|
|||||||
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
||||||
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
||||||
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
||||||
# 关闭VS自动Win32设计时预生成
|
# 关闭VS自动Win32设计时预生成
|
||||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
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_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")
|
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)
|
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
|
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
|
||||||
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
|
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
|
||||||
|
|
||||||
# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递
|
# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递
|
||||||
target_link_libraries(Updater PRIVATE
|
target_link_libraries(Updater PRIVATE
|
||||||
Common
|
Common
|
||||||
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
||||||
)
|
)
|
||||||
|
|
||||||
# Qt部署脚本
|
# Qt部署脚本
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||||
add_custom_command(TARGET Updater POST_BUILD
|
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()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
|
#include <QSaveFile>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
@@ -325,6 +326,52 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
|
|||||||
return true;
|
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)
|
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||||
{
|
{
|
||||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
QString url = m_serverAddr + "/api/v1/update/download-url";
|
||||||
@@ -640,6 +687,18 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
|
||||||
|
const QString& version, bool success)
|
||||||
|
{
|
||||||
|
QJsonArray files;
|
||||||
|
for (const FileDownloadItem& item : m_fileItems)
|
||||||
|
files.append(QJsonObject{{"path", item.path}, {"size", item.size}});
|
||||||
|
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version},
|
||||||
|
{"result", success ? "success" : "fail"}, {"files", files}};
|
||||||
|
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
|
||||||
|
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
|
||||||
|
}
|
||||||
|
|
||||||
void UpdaterLogic::reportResult(const QString& deviceId,
|
void UpdaterLogic::reportResult(const QString& deviceId,
|
||||||
const QString& fromVer,
|
const QString& fromVer,
|
||||||
const QString& toVer,
|
const QString& toVer,
|
||||||
@@ -648,6 +707,7 @@ void UpdaterLogic::reportResult(const QString& deviceId,
|
|||||||
QString url = m_serverAddr + "/api/v1/update/report";
|
QString url = m_serverAddr + "/api/v1/update/report";
|
||||||
|
|
||||||
QJsonObject body;
|
QJsonObject body;
|
||||||
|
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id");
|
||||||
body["device_id"] = deviceId;
|
body["device_id"] = deviceId;
|
||||||
body["from_version"] = fromVer;
|
body["from_version"] = fromVer;
|
||||||
body["to_version"] = toVer;
|
body["to_version"] = toVer;
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ public:
|
|||||||
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
||||||
bool saveManifestCache(const QString& cacheDir) const;
|
bool saveManifestCache(const QString& cacheDir) const;
|
||||||
bool loadManifestCache(const QString& cacheDir, const QString& version);
|
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 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 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);
|
bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
|
||||||
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
|
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
|
||||||
QString calcLocalFileSha256(const QString& filePath) const;
|
QString calcLocalFileSha256(const QString& filePath) const;
|
||||||
@@ -65,5 +68,6 @@ private:
|
|||||||
qint64 m_downloadCompletedBytes = 0;
|
qint64 m_downloadCompletedBytes = 0;
|
||||||
qint64 m_sessionDownloadedBytes = 0;
|
qint64 m_sessionDownloadedBytes = 0;
|
||||||
QString m_currentDownloadPath;
|
QString m_currentDownloadPath;
|
||||||
|
QString m_offlineError;
|
||||||
QElapsedTimer m_downloadTimer;
|
QElapsedTimer m_downloadTimer;
|
||||||
};
|
};
|
||||||
+115
-36
@@ -17,6 +17,9 @@
|
|||||||
#include "UpdateTransaction.h"
|
#include "UpdateTransaction.h"
|
||||||
#include "FileHelper.h"
|
#include "FileHelper.h"
|
||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
|
#include "TicketHelper.h"
|
||||||
|
#include "PolicyHelper.h"
|
||||||
|
#include <QVersionNumber>
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
@@ -25,15 +28,30 @@ int main(int argc, char* argv[])
|
|||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
QApplication::setApplicationName("Marsco Updater");
|
QApplication::setApplicationName("Marsco Updater");
|
||||||
|
|
||||||
if (argc < 5) {
|
UpdaterLogic logic;
|
||||||
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少应用、渠道或目标版本参数,请从 Launcher 启动。");
|
QString offlinePackagePath;
|
||||||
return -1;
|
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;
|
QString bootstrapResult;
|
||||||
for (int i = 5; i < argc; ++i) {
|
for (int i = 5; i < argc; ++i) {
|
||||||
const QString arg = argv[i];
|
const QString arg = argv[i];
|
||||||
@@ -44,7 +62,25 @@ int main(int argc, char* argv[])
|
|||||||
const QString targetDir = QApplication::applicationDirPath();
|
const QString targetDir = QApplication::applicationDirPath();
|
||||||
|
|
||||||
ConfigHelper& config = ConfigHelper::instance();
|
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");
|
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");
|
QString deviceId = config.getValue("Update", "device_id");
|
||||||
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
||||||
|
|
||||||
@@ -75,7 +111,6 @@ int main(int argc, char* argv[])
|
|||||||
progress.show();
|
progress.show();
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
|
|
||||||
UpdaterLogic logic;
|
|
||||||
UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId);
|
UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId);
|
||||||
const auto setProgress = [&](int value, const QString& message) {
|
const auto setProgress = [&](int value, const QString& message) {
|
||||||
progress.setValue(value);
|
progress.setValue(value);
|
||||||
@@ -103,22 +138,46 @@ int main(int argc, char* argv[])
|
|||||||
formatBytes(static_cast<qint64>(bytesPerSecond))));
|
formatBytes(static_cast<qint64>(bytesPerSecond))));
|
||||||
QApplication::processEvents();
|
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 auto fail = [&](const QString& title, const QString& message,
|
||||||
const QString& errorCode = QString("update_failed")) {
|
const QString& errorCode = QString("update_failed")) {
|
||||||
transaction.markFailed(errorCode, message);
|
transaction.markFailed(errorCode, message);
|
||||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, title, message);
|
QMessageBox::critical(nullptr, title, message);
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
const QString mainAppPath = QDir(targetDir).filePath("MainApp.exe");
|
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||||
const QString updaterPath = QDir(targetDir).filePath("Updater.exe");
|
const QString value = config.getValue("Runtime", key).trimmed();
|
||||||
const QString bootstrapPath = QDir(targetDir).filePath("Bootstrap.exe");
|
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 QString launchToken = config.getValue("App", "launch_token");
|
||||||
const auto launchMainApp = [&](const QString& healthFile = QString()) {
|
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));
|
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 = [&]() {
|
const auto bootstrapPlanFile = [&]() {
|
||||||
return QDir(targetDir).filePath("update/bootstrap_plan_" + transaction.transactionId() + ".txt");
|
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);
|
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
|
||||||
};
|
};
|
||||||
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
||||||
FileHelper::killProcess("MainApp.exe");
|
FileHelper::killProcess(mainExecutable);
|
||||||
transaction.markRollbackRequired(reason);
|
transaction.markRollbackRequired(reason);
|
||||||
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
@@ -140,7 +199,7 @@ int main(int argc, char* argv[])
|
|||||||
progress.close();
|
progress.close();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, title,
|
QMessageBox::critical(nullptr, title,
|
||||||
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
||||||
@@ -150,17 +209,19 @@ int main(int argc, char* argv[])
|
|||||||
if (resumingFromBootstrap) {
|
if (resumingFromBootstrap) {
|
||||||
QString resumeError;
|
QString resumeError;
|
||||||
if (!transaction.resumeExisting(&resumeError)) {
|
if (!transaction.resumeExisting(&resumeError)) {
|
||||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "事务续办失败",
|
QMessageBox::critical(nullptr, "事务续办失败",
|
||||||
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
fromVersion = transaction.fromVersion();
|
fromVersion = transaction.fromVersion();
|
||||||
|
if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode")))
|
||||||
|
offlinePackagePath = "__bootstrap_resumed_offline__";
|
||||||
if (bootstrapResult == "rolledback") {
|
if (bootstrapResult == "rolledback") {
|
||||||
const bool stateOk = config.setValue("App", "current_version", fromVersion);
|
const bool stateOk = config.setValue("App", "current_version", fromVersion);
|
||||||
transaction.markRolledBack();
|
transaction.markRolledBack();
|
||||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
reportUpdateResult(false);
|
||||||
if (stateOk) launchMainApp();
|
if (stateOk) launchMainApp();
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::warning(nullptr, "更新已回滚",
|
QMessageBox::warning(nullptr, "更新已回滚",
|
||||||
@@ -169,7 +230,7 @@ int main(int argc, char* argv[])
|
|||||||
return stateOk ? 0 : -1;
|
return stateOk ? 0 : -1;
|
||||||
}
|
}
|
||||||
if (bootstrapResult != "success") {
|
if (bootstrapResult != "success") {
|
||||||
logic.reportResult(deviceId, fromVersion, targetVersion, false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "自动回滚失败",
|
QMessageBox::critical(nullptr, "自动回滚失败",
|
||||||
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
||||||
@@ -177,16 +238,25 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
} else if (!transaction.initialize()) {
|
} else if (!transaction.initialize()) {
|
||||||
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
|
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, "正在获取并验证版本清单...");
|
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
|
||||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
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 (!logic.verifyManifestSignature()) {
|
||||||
if (resumingFromBootstrap)
|
if (resumingFromBootstrap)
|
||||||
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
||||||
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
||||||
}
|
}
|
||||||
const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache");
|
|
||||||
QStringList obsoletePaths;
|
QStringList obsoletePaths;
|
||||||
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
||||||
UpdaterLogic oldManifestLogic;
|
UpdaterLogic oldManifestLogic;
|
||||||
@@ -206,8 +276,9 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!resumingFromBootstrap) {
|
if (!resumingFromBootstrap) {
|
||||||
setProgress(25, "正在获取安全下载地址...");
|
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件...");
|
||||||
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
if (offlinePackagePath.isEmpty())
|
||||||
|
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||||
if (logic.getFileList().isEmpty())
|
if (logic.getFileList().isEmpty())
|
||||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||||
|
|
||||||
@@ -224,9 +295,17 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
const QString stagingDir = transaction.stagingDir();
|
const QString stagingDir = transaction.stagingDir();
|
||||||
setProgress(30, QString("正在下载并校验 %1 个版本文件...").arg(logic.getFileList().size()));
|
setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size()));
|
||||||
if (!logic.downloadAllFiles(stagingDir, targetDir))
|
if (!offlinePackagePath.isEmpty()) {
|
||||||
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
|
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, "正在校验完整版本文件...");
|
setProgress(58, "正在校验完整版本文件...");
|
||||||
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
||||||
@@ -238,15 +317,15 @@ int main(int argc, char* argv[])
|
|||||||
while (stagingFiles.hasNext())
|
while (stagingFiles.hasNext())
|
||||||
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
|
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
|
||||||
for (const QString& path : changedPaths) {
|
for (const QString& path : changedPaths) {
|
||||||
if (path.compare("Bootstrap.exe", Qt::CaseInsensitive) == 0)
|
if (path.compare(bootstrapExecutable, Qt::CaseInsensitive) == 0)
|
||||||
return fail("Bootstrap 无法自更新", "本次版本包含新的 Bootstrap.exe。请使用安装包升级 Bootstrap,再重新发布业务版本。", "bootstrap_self_update_blocked");
|
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapExecutable), "bootstrap_self_update_blocked");
|
||||||
}
|
}
|
||||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||||
|
|
||||||
setProgress(66, "正在关闭主程序...");
|
setProgress(66, "正在关闭主程序...");
|
||||||
if (!FileHelper::killProcess("MainApp.exe"))
|
if (!FileHelper::killProcess(mainExecutable))
|
||||||
return fail("无法关闭主程序", "MainApp.exe 仍在运行,请手动关闭后重试。", "mainapp_close_failed");
|
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed");
|
||||||
|
|
||||||
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
|
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
|
||||||
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
||||||
@@ -300,16 +379,16 @@ int main(int argc, char* argv[])
|
|||||||
QFile::remove(healthFile);
|
QFile::remove(healthFile);
|
||||||
setProgress(94, "正在启动新版本并等待健康确认...");
|
setProgress(94, "正在启动新版本并等待健康确认...");
|
||||||
if (!launchMainApp(healthFile))
|
if (!launchMainApp(healthFile))
|
||||||
return delegateRollback("启动失败", "MainApp.exe 无法启动。");
|
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable));
|
||||||
|
|
||||||
QElapsedTimer healthTimer;
|
QElapsedTimer healthTimer;
|
||||||
healthTimer.start();
|
healthTimer.start();
|
||||||
while (healthTimer.elapsed() < 15000 && !QFile::exists(healthFile)) {
|
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
QThread::msleep(100);
|
QThread::msleep(100);
|
||||||
}
|
}
|
||||||
if (!QFile::exists(healthFile))
|
if (!QFile::exists(healthFile))
|
||||||
return delegateRollback("启动确认失败", "新版本在 15 秒内没有完成启动健康确认。");
|
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs));
|
||||||
|
|
||||||
setProgress(99, "正在提交更新事务...");
|
setProgress(99, "正在提交更新事务...");
|
||||||
if (!transaction.commit())
|
if (!transaction.commit())
|
||||||
@@ -317,7 +396,7 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
QFile::remove(healthFile);
|
QFile::remove(healthFile);
|
||||||
QFile::remove(bootstrapPlanFile());
|
QFile::remove(bootstrapPlanFile());
|
||||||
logic.reportResult(deviceId, fromVersion, targetVersion, true);
|
reportUpdateResult(true);
|
||||||
progress.setValue(100);
|
progress.setValue(100);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
||||||
|
|||||||
+528
-71
@@ -5,65 +5,339 @@
|
|||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Marsco 发布控制台</title>
|
<title>Marsco 发布控制台</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: Arial, Helvetica, sans-serif; margin: 20px; background: #f4f6fb; color: #222; }
|
:root {
|
||||||
h1, h2 { margin: 0 0 12px; }
|
--bg:#f6f7fb;
|
||||||
.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); }
|
--surface:#fff;
|
||||||
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
--ink:#172033;
|
||||||
.row label { min-width: 110px; margin: 0; }
|
--muted:#667085;
|
||||||
.row input, .row select, .row button { padding: 8px 10px; font-size: 14px; }
|
--line:#dfe5ee;
|
||||||
.row input, .row select { min-width: 240px; border: 1px solid #c9cdd5; border-radius: 6px; }
|
--field:#fdfefe;
|
||||||
.row button { border: 1px solid #5a8efc; background: #5a8efc; color: #fff; border-radius: 6px; cursor: pointer; }
|
--brand:#3157d5;
|
||||||
.row button:hover { background: #4b7ce6; }
|
--brand-hover:#2447bc;
|
||||||
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
--danger:#d92d20;
|
||||||
th, td { border: 1px solid #e1e4ea; padding: 10px; text-align: left; vertical-align: middle; }
|
--success:#067647;
|
||||||
th { background: #f1f5fb; }
|
--warning:#9a6700;
|
||||||
td button { margin-right: 6px; }
|
}
|
||||||
pre { background: #0f172a; color: #f8fafc; border-radius: 10px; padding: 14px; min-height: 120px; overflow-x: auto; }
|
|
||||||
.small { font-size: 13px; color: #6b7280; }
|
|
||||||
.wide { width: 100%; }
|
|
||||||
|
|
||||||
:root { --ink:#172033; --muted:#667085; --line:#e4e9f1; --paper:#fff; --brand:#3157d5; --brand-dark:#2447bc; --soft:#f4f6fa; --danger:#d92d20; --success:#067647; }
|
|
||||||
* { box-sizing:border-box; }
|
* { 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; }
|
html { background:var(--bg); }
|
||||||
body::before { content:""; position:absolute; inset:0 0 auto; height:190px; z-index:-1; background:linear-gradient(120deg,#121a32,#294681); }
|
body {
|
||||||
h1 { margin:0 -24px 28px; padding:32px 24px 55px; color:#fff; font-size:30px; letter-spacing:-.03em; }
|
max-width:1280px;
|
||||||
h1::before { content:"MARSCO UPDATE SERVICE"; display:block; margin-bottom:7px; color:#adc0ff; font-size:11px; letter-spacing:.16em; }
|
margin:0 auto;
|
||||||
h1::after { content:" · 软件发布与升级管理"; color:#d7e0ff; font-size:14px; font-weight:400; letter-spacing:0; }
|
padding:0 24px 48px;
|
||||||
h2 { margin:0; font-size:17px; }
|
color:var(--ink);
|
||||||
.section { border:1px solid var(--line); border-radius:16px; padding:22px; margin-bottom:18px; box-shadow:0 8px 28px rgba(24,36,61,.06); }
|
background:var(--bg);
|
||||||
.section h2 { padding-bottom:14px; margin-bottom:17px; border-bottom:1px solid #edf0f4; }
|
font:14px/1.45 Inter,"Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
|
||||||
.row { gap:12px; margin-top:12px; align-items:flex-end; }
|
}
|
||||||
.row label { min-width:auto; color:#344054; font-size:13px; font-weight:650; }
|
h1 {
|
||||||
.row input,.row select { min-width:220px; padding:10px 12px; border:1px solid #d0d5dd; border-radius:9px; outline:none; transition:.18s ease; }
|
margin:0 -24px 22px;
|
||||||
.row input:focus,.row select:focus { border-color:#7893e8; box-shadow:0 0 0 3px rgba(49,87,213,.12); }
|
padding:24px;
|
||||||
.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; }
|
color:var(--ink);
|
||||||
.row button:hover,td button:hover { transform:translateY(-1px); border-color:#98a2b3; box-shadow:0 3px 9px rgba(16,24,40,.08); }
|
background:var(--surface);
|
||||||
#saveTokenBtn,#addAppBtn,#publishBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
|
border-bottom:1px solid var(--line);
|
||||||
#saveTokenBtn:hover,#addAppBtn:hover,#publishBtn:hover { background:var(--brand-dark); }
|
box-shadow:0 1px 2px rgba(16,24,40,.04);
|
||||||
button:disabled { opacity:.55; cursor:not-allowed; transform:none!important; }
|
font-size:26px;
|
||||||
input[type=file] { padding:8px; background:#fafbfc; }
|
line-height:1.2;
|
||||||
input[type=file]::file-selector-button { border:0; border-radius:7px; padding:7px 11px; margin-right:10px; color:#344054; background:#e9edf5; cursor:pointer; }
|
letter-spacing:0;
|
||||||
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; }
|
h1::before {
|
||||||
th { color:#475467; background:#f8f9fb; font-size:12px; letter-spacing:.02em; }
|
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:last-child td { border-bottom:0; }
|
||||||
tbody tr:hover { background:#fafbff; }
|
tbody tr:hover { background:#fafbff; }
|
||||||
td button[data-action=delete] { color:var(--danger); border-color:#fecdca; background:#fff7f6; }
|
td button[data-action="delete"] {
|
||||||
.badge { display:inline-flex; border-radius:999px; padding:4px 8px; font-size:12px; font-weight:700; color:#475467; background:#f2f4f7; }
|
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.success { color:var(--success); background:#ecfdf3; }
|
||||||
.badge.fail { color:var(--danger); background:#fef3f2; }
|
.badge.fail { color:var(--danger); background:#fef3f2; }
|
||||||
.empty { padding:30px 12px!important; color:var(--muted); text-align:center; }
|
.empty {
|
||||||
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; }
|
padding:28px 12px!important;
|
||||||
.small { margin-top:10px; color:var(--muted); }
|
color:var(--muted);
|
||||||
.token-change { margin-top:16px; padding:14px; border:1px solid #dce3f1; border-radius:11px; background:#f8faff; }
|
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; }
|
.token-change[hidden] { display:none; }
|
||||||
#toggleTokenBtn { min-width:64px; }
|
.toast {
|
||||||
#changeTokenBtn { color:var(--brand); border-color:#b9c7ef; background:#f7f9ff; }
|
position:fixed;
|
||||||
#confirmChangeTokenBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
|
right:22px;
|
||||||
.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; }
|
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.show { opacity:1; transform:translateY(0); }
|
||||||
.toast.error { background:#b42318; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body><div id="toast" class="toast"></div>
|
<body><div id="toast" class="toast"></div>
|
||||||
@@ -77,9 +351,19 @@
|
|||||||
<button id="saveTokenBtn" type="button">登录并保存</button>
|
<button id="saveTokenBtn" type="button">登录并保存</button>
|
||||||
<button id="changeTokenBtn" type="button">更改令牌</button>
|
<button id="changeTokenBtn" type="button">更改令牌</button>
|
||||||
</div>
|
</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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,6 +383,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<h2>发布新版本</h2>
|
<h2>发布新版本</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -107,7 +403,8 @@
|
|||||||
<label for="publishVersion">版本号</label>
|
<label for="publishVersion">版本号</label>
|
||||||
<input id="publishVersion" type="text" value="1.0.0" />
|
<input id="publishVersion" type="text" value="1.0.0" />
|
||||||
<label for="publishChannel">渠道</label>
|
<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>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="publishFiles">软件根目录</label>
|
<label for="publishFiles">软件根目录</label>
|
||||||
@@ -131,6 +428,7 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>版本</th>
|
<th>版本</th>
|
||||||
<th>渠道</th>
|
<th>渠道</th>
|
||||||
|
<th>协议</th>
|
||||||
<th>最新</th>
|
<th>最新</th>
|
||||||
<th>创建时间</th>
|
<th>创建时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
@@ -143,7 +441,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>版本运行策略</h2>
|
<h2>版本运行策略</h2>
|
||||||
<div class="row">
|
<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="policyForceUpdate" type="checkbox" /> 强制升级</label>
|
||||||
<label><input id="policyAllowRollback" type="checkbox" /> 允许降级</label>
|
<label><input id="policyAllowRollback" type="checkbox" /> 允许降级</label>
|
||||||
<label><input id="policyOfflineAllowed" type="checkbox" checked /> 允许离线启动</label>
|
<label><input id="policyOfflineAllowed" type="checkbox" checked /> 允许离线启动</label>
|
||||||
@@ -160,6 +458,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<h2>升级日志</h2>
|
<h2>升级日志</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -179,14 +496,26 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
<div class="section">
|
||||||
<h2>调试输出</h2>
|
<h2>调试输出</h2>
|
||||||
<pre id="output">准备就绪...</pre>
|
<pre id="output">准备就绪...</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<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 adminTokenInput = document.getElementById('adminToken');
|
||||||
|
const apiBaseLabel = document.getElementById('apiBaseLabel');
|
||||||
const toggleTokenBtn = document.getElementById('toggleTokenBtn');
|
const toggleTokenBtn = document.getElementById('toggleTokenBtn');
|
||||||
const tokenChangePanel = document.getElementById('tokenChangePanel');
|
const tokenChangePanel = document.getElementById('tokenChangePanel');
|
||||||
const newAdminTokenInput = document.getElementById('newAdminToken');
|
const newAdminTokenInput = document.getElementById('newAdminToken');
|
||||||
@@ -198,16 +527,23 @@
|
|||||||
const publishAppIdInput = document.getElementById('publishAppId');
|
const publishAppIdInput = document.getElementById('publishAppId');
|
||||||
const publishVersionInput = document.getElementById('publishVersion');
|
const publishVersionInput = document.getElementById('publishVersion');
|
||||||
const publishChannelInput = document.getElementById('publishChannel');
|
const publishChannelInput = document.getElementById('publishChannel');
|
||||||
|
const publishProtocolInput = document.getElementById('publishProtocol');
|
||||||
const publishFilesInput = document.getElementById('publishFiles');
|
const publishFilesInput = document.getElementById('publishFiles');
|
||||||
const publishFileSummary = document.getElementById('publishFileSummary');
|
const publishFileSummary = document.getElementById('publishFileSummary');
|
||||||
const publishFilePreview = document.getElementById('publishFilePreview');
|
const publishFilePreview = document.getElementById('publishFilePreview');
|
||||||
const publishBtn = document.getElementById('publishBtn');
|
const publishBtn = document.getElementById('publishBtn');
|
||||||
const versionRows = document.getElementById('versionRows');
|
const versionRows = document.getElementById('versionRows');
|
||||||
const reportRows = document.getElementById('reportRows');
|
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 currentToken = localStorage.getItem('admin_token') || '';
|
||||||
let apps = [];
|
let apps = [];
|
||||||
let currentAppId = '';
|
let currentAppId = '';
|
||||||
|
let channels = [];
|
||||||
|
|
||||||
function log(message) {
|
function log(message) {
|
||||||
if (typeof message === 'string') {
|
if (typeof message === 'string') {
|
||||||
@@ -239,6 +575,7 @@
|
|||||||
const url = apiBase + path;
|
const url = apiBase + path;
|
||||||
const reqOptions = {
|
const reqOptions = {
|
||||||
...options,
|
...options,
|
||||||
|
cache: 'no-store',
|
||||||
headers: { ...(options.headers || {}), ...getHeaders(options.contentType) },
|
headers: { ...(options.headers || {}), ...getHeaders(options.contentType) },
|
||||||
};
|
};
|
||||||
delete reqOptions.contentType;
|
delete reqOptions.contentType;
|
||||||
@@ -321,28 +658,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chrBackslash = String.fromCharCode(92);
|
const chrBackslash = String.fromCharCode(92);
|
||||||
|
let releaseMainExecutable = 'MainApp.exe';
|
||||||
function selectedUploadEntries() {
|
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']);
|
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 => {
|
return Array.from(publishFilesInput.files).map(file => ({ file, path: relativePathForFile(file) })).filter(item => {
|
||||||
const path = item.path.toLowerCase();
|
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) {
|
function validateReleaseRoot(entries) {
|
||||||
if (!entries.length) return '请选择软件发布根目录';
|
if (!entries.length) return '请选择软件发布根目录';
|
||||||
const lowerPaths = entries.map(item => item.path.toLowerCase());
|
const lowerPaths = entries.map(item => item.path.toLowerCase());
|
||||||
if (!lowerPaths.includes('mainapp.exe')) {
|
const mainName = releaseMainExecutable.toLowerCase();
|
||||||
const nestedMain = lowerPaths.find(path => path.endsWith('/mainapp.exe'));
|
if (!lowerPaths.includes(mainName)) {
|
||||||
|
const nestedMain = lowerPaths.find(path => path.endsWith('/' + mainName));
|
||||||
if (nestedMain) {
|
if (nestedMain) {
|
||||||
const expectedRoot = nestedMain.slice(0, -'/mainapp.exe'.length);
|
const expectedRoot = nestedMain.slice(0, -(mainName.length + 1));
|
||||||
return '选择层级过高:MainApp.exe 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
|
return '选择层级过高:' + releaseMainExecutable + ' 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
|
||||||
}
|
}
|
||||||
return '所选目录根级没有 MainApp.exe,不能作为软件发布根目录';
|
return '所选目录根级没有 ' + releaseMainExecutable + ',不能作为软件发布根目录';
|
||||||
}
|
}
|
||||||
|
const duplicateMain = lowerPaths.find(path => path.endsWith('/' + mainName));
|
||||||
|
if (duplicateMain) return '目录中存在嵌套的重复主程序 ' + duplicateMain + ',请选择干净的 Release 输出目录';
|
||||||
return '';
|
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() {
|
function renderSelectedDirectory() {
|
||||||
const allCount = publishFilesInput.files.length;
|
const allCount = publishFilesInput.files.length;
|
||||||
const entries = selectedUploadEntries();
|
const entries = selectedUploadEntries();
|
||||||
@@ -356,19 +708,29 @@
|
|||||||
publishFilePreview.textContent = paths.length ? paths.join('\n') + (entries.length > 30 ? '\n... 其余 ' + (entries.length - 30) + ' 个文件' : '') : '尚未选择发布目录';
|
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() {
|
async function publishVersion() {
|
||||||
const appId = publishAppIdInput.value.trim();
|
const appId = publishAppIdInput.value.trim();
|
||||||
const version = publishVersionInput.value.trim();
|
const version = publishVersionInput.value.trim();
|
||||||
const channel = publishChannelInput.value.trim() || 'stable';
|
const channel = publishChannelInput.value.trim();
|
||||||
const entries = selectedUploadEntries();
|
const entries = selectedUploadEntries();
|
||||||
if (!appId) { log('请选择或填写 App ID'); return; }
|
if (!appId) { log('请选择或填写 App ID'); return; }
|
||||||
if (!version) { log('请输入版本号'); return; }
|
if (!version) { log('请输入版本号'); return; }
|
||||||
|
if (!channel) { notify('当前应用没有已启用渠道', true); return; }
|
||||||
const rootError = validateReleaseRoot(entries);
|
const rootError = validateReleaseRoot(entries);
|
||||||
if (rootError) { log(rootError); notify(rootError, true); return; }
|
if (rootError) { log(rootError); notify(rootError, true); return; }
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('app_id', appId);
|
formData.append('app_id', appId);
|
||||||
formData.append('version', version);
|
formData.append('version', version);
|
||||||
formData.append('channel', channel);
|
formData.append('channel', channel);
|
||||||
|
formData.append('client_protocol', String(Math.max(1, Number(publishProtocolInput.value) || 3)));
|
||||||
for (const item of entries) {
|
for (const item of entries) {
|
||||||
formData.append('files', item.file, item.file.name);
|
formData.append('files', item.file, item.file.name);
|
||||||
formData.append('relative_paths', item.path);
|
formData.append('relative_paths', item.path);
|
||||||
@@ -412,17 +774,20 @@
|
|||||||
try {
|
try {
|
||||||
const data = await request(`/admin/version/list?app_id=${encodeURIComponent(appId)}`);
|
const data = await request(`/admin/version/list?app_id=${encodeURIComponent(appId)}`);
|
||||||
const rows = data.list || [];
|
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 => {
|
rows.forEach(item => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${item.id}</td>
|
<td>${item.id}</td>
|
||||||
<td>${item.version}</td>
|
<td>${item.version}</td>
|
||||||
<td>${item.channel}</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><span class="badge ${item.latest ? 'success' : ''}">${item.latest ? '当前最新' : '历史版本'}</span></td>
|
||||||
<td>${item.create_time || ''}</td>
|
<td>${item.create_time || ''}</td>
|
||||||
<td>
|
<td>
|
||||||
<button type="button" data-action="set-latest" data-id="${item.id}">设为最新</button>
|
<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>
|
<button type="button" data-action="delete" data-id="${item.id}">删除</button>
|
||||||
</td>
|
</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) {
|
async function setLatest(versionId) {
|
||||||
log(`正在设置版本 ${versionId} 为最新...`);
|
log(`正在设置版本 ${versionId} 为最新...`);
|
||||||
try {
|
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) {
|
async function deleteVersion(versionId) {
|
||||||
if (!window.confirm(`确定删除版本记录 #${versionId} 及其云端文件吗?此操作不可撤销。`)) return;
|
if (!window.confirm(`确定删除版本记录 #${versionId} 及其云端文件吗?此操作不可撤销。`)) return;
|
||||||
log(`正在删除版本 ${versionId} ...`);
|
log(`正在删除版本 ${versionId} ...`);
|
||||||
@@ -502,6 +884,36 @@
|
|||||||
} catch (err) { log(err); notify('保存策略失败', true); }
|
} 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() {
|
async function loadReports() {
|
||||||
log('正在加载升级日志...');
|
log('正在加载升级日志...');
|
||||||
try {
|
try {
|
||||||
@@ -539,12 +951,17 @@
|
|||||||
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
|
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
|
||||||
const token = adminTokenInput.value.trim();
|
const token = adminTokenInput.value.trim();
|
||||||
if (!token) { notify('令牌不能为空', true); return; }
|
if (!token) { notify('令牌不能为空', true); return; }
|
||||||
|
const previousToken = currentToken;
|
||||||
currentToken = token;
|
currentToken = token;
|
||||||
localStorage.setItem('admin_token', currentToken);
|
|
||||||
try {
|
try {
|
||||||
await loadApps();
|
await request('/admin/auth/check');
|
||||||
|
localStorage.setItem('admin_token', currentToken);
|
||||||
|
await loadConsoleData();
|
||||||
notify('令牌验证成功并已保存');
|
notify('令牌验证成功并已保存');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
currentToken = previousToken;
|
||||||
|
updateTokenInput();
|
||||||
|
log(err);
|
||||||
notify('令牌验证失败', true);
|
notify('令牌验证失败', true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -561,10 +978,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
|
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
|
||||||
|
const currentInputToken = adminTokenInput.value.trim();
|
||||||
const nextToken = newAdminTokenInput.value.trim();
|
const nextToken = newAdminTokenInput.value.trim();
|
||||||
const confirmation = confirmAdminTokenInput.value.trim();
|
const confirmation = confirmAdminTokenInput.value.trim();
|
||||||
|
if (!currentInputToken) { notify('请先输入当前管理员令牌', true); return; }
|
||||||
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
|
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
|
||||||
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
|
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
|
||||||
|
currentToken = currentInputToken;
|
||||||
try {
|
try {
|
||||||
const result = await request('/admin/token/change', { method: 'POST', contentType: 'application/json', body: JSON.stringify({ new_token: nextToken }) });
|
const result = await request('/admin/token/change', { method: 'POST', contentType: 'application/json', body: JSON.stringify({ new_token: nextToken }) });
|
||||||
currentToken = nextToken;
|
currentToken = nextToken;
|
||||||
@@ -574,19 +994,30 @@
|
|||||||
newAdminTokenInput.value = '';
|
newAdminTokenInput.value = '';
|
||||||
confirmAdminTokenInput.value = '';
|
confirmAdminTokenInput.value = '';
|
||||||
log(result);
|
log(result);
|
||||||
notify('管理员令牌已更改');
|
notify('管理员令牌已在当前服务进程生效,请同步修改 .env');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(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('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);
|
document.getElementById('addAppBtn').addEventListener('click', addApp);
|
||||||
publishBtn.addEventListener('click', publishVersion);
|
publishBtn.addEventListener('click', publishVersion);
|
||||||
publishFilesInput.addEventListener('change', renderSelectedDirectory);
|
publishFilesInput.addEventListener('change', renderSelectedDirectory);
|
||||||
document.getElementById('refreshVersionsBtn').addEventListener('click', loadVersions);
|
document.getElementById('refreshVersionsBtn').addEventListener('click', loadVersions);
|
||||||
document.getElementById('refreshReportsBtn').addEventListener('click', loadReports);
|
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('loadPolicyBtn').addEventListener('click', loadPolicy);
|
||||||
document.getElementById('savePolicyBtn').addEventListener('click', savePolicy);
|
document.getElementById('savePolicyBtn').addEventListener('click', savePolicy);
|
||||||
document.getElementById('policyChannel').addEventListener('change', loadPolicy);
|
document.getElementById('policyChannel').addEventListener('change', loadPolicy);
|
||||||
@@ -597,7 +1028,7 @@
|
|||||||
publishAppIdInput.value = currentAppId;
|
publishAppIdInput.value = currentAppId;
|
||||||
}
|
}
|
||||||
currentAppLabel.textContent = currentAppId || '未选择';
|
currentAppLabel.textContent = currentAppId || '未选择';
|
||||||
if (currentAppId) loadPolicy();
|
if (currentAppId) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
versionRows.addEventListener('click', async (event) => {
|
versionRows.addEventListener('click', async (event) => {
|
||||||
@@ -607,15 +1038,41 @@
|
|||||||
const id = button.dataset.id;
|
const id = button.dataset.id;
|
||||||
if (action === 'set-latest') {
|
if (action === 'set-latest') {
|
||||||
await setLatest(id);
|
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') {
|
} else if (action === 'delete') {
|
||||||
await deleteVersion(id);
|
await deleteVersion(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('load', async () => {
|
async function loadConsoleData() {
|
||||||
updateTokenInput();
|
await loadRuntimeConfig();
|
||||||
|
renderSelectedDirectory();
|
||||||
await loadApps();
|
await loadApps();
|
||||||
await loadReports();
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
{
|
{
|
||||||
"app_id": "your_app_id",
|
"app_id": "simcae",
|
||||||
"app_name": "Your Application",
|
"app_name": "SimCAE",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"api_base_url": "http://127.0.0.1:8000",
|
|
||||||
"platform": "windows",
|
|
||||||
"arch": "x64",
|
|
||||||
"current_version": "1.0.0",
|
"current_version": "1.0.0",
|
||||||
"client_token": "replace_me",
|
"client_protocol": "3",
|
||||||
"launch_token": "replace_me",
|
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
|
||||||
"request_timeout_ms": 5000,
|
"license_key": "",
|
||||||
|
"api_base_url": "http://YOUR_SERVER_IP:8000",
|
||||||
|
"client_token": "SimCAEClientToken2026",
|
||||||
|
"request_timeout_ms": "5000",
|
||||||
"temp_folder": "update_temp",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"max_policy_seq": 0,
|
|
||||||
"last_success_run_at": "",
|
|
||||||
"last_online_verified_at": "",
|
|
||||||
"last_success_version": ""
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"disabled_versions":[],"force_update":false,"offline_allowed":true,"policy_seq":1,"valid_until":"2099-12-31T23:59:59Z","signature":"d6982cb02d9a0c6fb6b7176961a7736dbee0decfbd5e422bd189521c72664205"}
|
|
||||||
@@ -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."
|
||||||
@@ -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"
|
||||||
@@ -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
@@ -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 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
**
|
||||||
|
!Dockerfile
|
||||||
|
!requirements.txt
|
||||||
|
!main.py
|
||||||
|
!db.py
|
||||||
|
!minio_tool.py
|
||||||
|
!tables.sql
|
||||||
|
!admin.html
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Docker 镜像名称。通常不用改;重新打镜像并改版本号时才需要同步修改。
|
||||||
|
SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0
|
||||||
|
|
||||||
|
# 服务端端口。浏览器访问 http://服务器IP:8000/。
|
||||||
|
SERVER_PORT=8000
|
||||||
|
|
||||||
|
# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。
|
||||||
|
APP_UID=1000
|
||||||
|
APP_GID=1000
|
||||||
|
|
||||||
|
# 管理后台标题。可不填或不改。
|
||||||
|
SERVICE_TITLE=SimCAE Update Service
|
||||||
|
|
||||||
|
# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。
|
||||||
|
CORS_ALLOW_ORIGINS=*
|
||||||
|
|
||||||
|
# 发布包目标平台。当前客户端是 Windows x64,通常不用改。
|
||||||
|
TARGET_PLATFORM=windows
|
||||||
|
TARGET_ARCH=x64
|
||||||
|
|
||||||
|
# 业务主程序文件名。必须和你发布目录根级的主 exe 名称一致。
|
||||||
|
RELEASE_MAIN_EXECUTABLE=SimCAE.exe
|
||||||
|
|
||||||
|
# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。
|
||||||
|
SIGNING_KEY_ID=manifest-key-v1
|
||||||
|
|
||||||
|
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
|
||||||
|
CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||||
|
|
||||||
|
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。
|
||||||
|
ADMIN_TOKEN=SimCAEAdminToken2026
|
||||||
|
|
||||||
|
# 崩溃报告接口。已给默认值,可直接试跑;正式部署建议修改。
|
||||||
|
CRASH_SERVICE_VERSION=1.0.0
|
||||||
|
CRASH_REPORT_TOKEN=SimCAECrashReportToken2026
|
||||||
|
CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026
|
||||||
|
|
||||||
|
# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。
|
||||||
|
CRASH_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# 崩溃报告大小限制。通常不用改。
|
||||||
|
CRASH_METADATA_MAX_KB=256
|
||||||
|
CRASH_MINIDUMP_MAX_MB=128
|
||||||
|
CRASH_ATTACHMENTS_MAX_MB=64
|
||||||
|
CRASH_REQUEST_MAX_MB=200
|
||||||
|
CRASH_SYMBOLS_MAX_MB=512
|
||||||
|
|
||||||
|
# MinIO 端口。通常不用改;如果端口被占用再改。
|
||||||
|
MINIO_API_PORT=9000
|
||||||
|
MINIO_CONSOLE_PORT=9001
|
||||||
|
|
||||||
|
# MinIO 用户名和密码。Docker 启动 MinIO 时会用这里的值初始化账号;已给默认值,可直接试跑,正式部署建议修改。
|
||||||
|
MINIO_ACCESS_KEY=simcae_minio_admin
|
||||||
|
MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe
|
||||||
|
|
||||||
|
# MinIO 存储桶名称。通常不用改。
|
||||||
|
MINIO_BUCKET=updates
|
||||||
|
|
||||||
|
# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
|
||||||
|
|
||||||
|
# MinIO 预签名下载链接有效期,单位分钟。通常不用改。
|
||||||
|
SIGN_EXPIRE_MIN=60
|
||||||
|
|
||||||
|
# MinIO 连接超时和重试参数。通常不用改。
|
||||||
|
MINIO_CONNECT_TIMEOUT_SEC=2
|
||||||
|
MINIO_READ_TIMEOUT_SEC=5
|
||||||
|
MINIO_RETRY_TOTAL=1
|
||||||
|
MINIO_HEALTH_TIMEOUT_SEC=2
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 服务监听地址和端口。本机开发一般保持 0.0.0.0:8000。
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
SERVER_PORT=8000
|
||||||
|
|
||||||
|
# 本地开发时一般不用改;Docker 部署主要看 .env.docker.example。
|
||||||
|
APP_UID=1000
|
||||||
|
APP_GID=1000
|
||||||
|
SERVICE_TITLE=Software Update Service
|
||||||
|
ADMIN_HTML_PATH=../client/admin.html
|
||||||
|
CORS_ALLOW_ORIGINS=*
|
||||||
|
TARGET_PLATFORM=windows
|
||||||
|
TARGET_ARCH=x64
|
||||||
|
RELEASE_MAIN_EXECUTABLE=MainApp.exe
|
||||||
|
SIGNING_KEY_ID=manifest-key-v1
|
||||||
|
|
||||||
|
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
|
||||||
|
CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||||
|
|
||||||
|
# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。
|
||||||
|
ADMIN_TOKEN=SimCAEAdminToken2026
|
||||||
|
|
||||||
|
# 数据库和 Manifest 私钥路径。本地开发一般不用改。
|
||||||
|
DB_FILE=mini.db
|
||||||
|
SQL_FILE=tables.sql
|
||||||
|
MANIFEST_PRIVATE_KEY_PATH=keys/manifest_private_key.pem
|
||||||
|
|
||||||
|
# 上传暂存和本地兜底存储目录。通常不用改。
|
||||||
|
UPLOAD_SPOOL_DIR=upload_spool
|
||||||
|
UPLOAD_SPACE_RESERVE_MB=256
|
||||||
|
LOCAL_UPLOAD_ROOT=local_uploads
|
||||||
|
LOCAL_FILE_URL_BASE=http://127.0.0.1:8000/static
|
||||||
|
|
||||||
|
# SimCAE 崩溃报告接口。接入崩溃上报时必须改;不接入崩溃上报也建议改成随机字符串。
|
||||||
|
CRASH_SERVICE_VERSION=1.0.0
|
||||||
|
CRASH_REPORT_TOKEN=SimCAECrashReportToken2026
|
||||||
|
CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026
|
||||||
|
|
||||||
|
# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。
|
||||||
|
CRASH_ADMIN_TOKEN=
|
||||||
|
CRASH_STORAGE_ROOT=crash_storage
|
||||||
|
|
||||||
|
# 崩溃报告大小限制。通常不用改。
|
||||||
|
CRASH_METADATA_MAX_KB=256
|
||||||
|
CRASH_MINIDUMP_MAX_MB=128
|
||||||
|
CRASH_ATTACHMENTS_MAX_MB=64
|
||||||
|
CRASH_REQUEST_MAX_MB=200
|
||||||
|
CRASH_SYMBOLS_MAX_MB=512
|
||||||
|
|
||||||
|
# MinIO 地址。本地开发如果 MinIO 在本机 9000 端口,保持不变。
|
||||||
|
MINIO_ENDPOINT=127.0.0.1:9000
|
||||||
|
|
||||||
|
# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
|
||||||
|
|
||||||
|
# MinIO 用户名、密码和桶。必须和你的 MinIO 配置一致。
|
||||||
|
MINIO_ACCESS_KEY=simcae_minio_admin
|
||||||
|
MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe
|
||||||
|
MINIO_BUCKET=updates
|
||||||
|
MINIO_SECURE=false
|
||||||
|
|
||||||
|
# MinIO 预签名下载链接和连接参数。通常不用改。
|
||||||
|
SIGN_EXPIRE_MIN=60
|
||||||
|
MINIO_CONNECT_TIMEOUT_SEC=2
|
||||||
|
MINIO_READ_TIMEOUT_SEC=5
|
||||||
|
MINIO_RETRY_TOTAL=1
|
||||||
|
MINIO_HEALTH_TIMEOUT_SEC=2
|
||||||
|
|
||||||
|
# 本地演示便利项。Docker 部署时会强制关闭自动启动 MinIO。
|
||||||
|
MINIO_AUTO_START=true
|
||||||
|
MINIO_AUTO_DOWNLOAD=false
|
||||||
|
MINIO_BIN_PATH=minio
|
||||||
|
MINIO_DATA_DIR=minio_data
|
||||||
|
MINIO_LISTEN_ADDRESS=:9000
|
||||||
|
MINIO_START_WAIT_SEC=5
|
||||||
|
MINIO_DOWNLOAD_URL=https://dl.min.io/server/minio/release/linux-amd64/minio
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp
|
||||||
|
|
||||||
|
COPY server/requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./
|
||||||
|
COPY --chown=updateapp:updateapp client/admin.html ./admin.html
|
||||||
|
|
||||||
|
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
||||||
|
&& chown -R updateapp:updateapp /app /data \
|
||||||
|
&& chmod 644 /app/admin.html
|
||||||
|
|
||||||
|
USER updateapp
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
+11
-3
@@ -5,8 +5,16 @@ from dotenv import load_dotenv
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
DB_FILE = os.getenv("DB_FILE")
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
SQL_FILE = os.getenv("SQL_FILE")
|
|
||||||
|
|
||||||
|
def configured_path(name: str, default: str) -> Path:
|
||||||
|
path = Path(os.getenv(name, default)).expanduser()
|
||||||
|
return path if path.is_absolute() else BASE_DIR / path
|
||||||
|
|
||||||
|
|
||||||
|
DB_FILE = configured_path("DB_FILE", "mini.db")
|
||||||
|
SQL_FILE = configured_path("SQL_FILE", "tables.sql")
|
||||||
|
|
||||||
# 初始化数据库
|
# 初始化数据库
|
||||||
def init_db():
|
def init_db():
|
||||||
@@ -27,4 +35,4 @@ def get_conn():
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_db()
|
init_db()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- ./minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
||||||
|
mc mb --ignore-existing "update-store/$${MINIO_BUCKET}"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||||
|
|
||||||
|
api:
|
||||||
|
image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0}
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
SERVER_HOST: 0.0.0.0
|
||||||
|
SERVER_PORT: 8000
|
||||||
|
DB_FILE: /data/mini.db
|
||||||
|
SQL_FILE: /app/tables.sql
|
||||||
|
ADMIN_HTML_PATH: /app/admin.html
|
||||||
|
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||||
|
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||||
|
MINIO_DATA_DIR: /data
|
||||||
|
CRASH_STORAGE_ROOT: /data/crash_storage
|
||||||
|
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required}
|
||||||
|
MINIO_AUTO_START: "false"
|
||||||
|
MINIO_SECURE: "false"
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./runtime:/data
|
||||||
|
- ./keys:/run/secrets/update-keys:ro
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
ports:
|
||||||
|
- "${MINIO_API_PORT:-9000}:9000"
|
||||||
|
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||||
|
volumes:
|
||||||
|
- ./minio_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
|
command:
|
||||||
|
- >-
|
||||||
|
mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" &&
|
||||||
|
mc mb --ignore-existing "update-store/$${MINIO_BUCKET}"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
|
||||||
|
MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required}
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: server/Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
SERVER_HOST: 0.0.0.0
|
||||||
|
SERVER_PORT: 8000
|
||||||
|
DB_FILE: /data/mini.db
|
||||||
|
SQL_FILE: /app/tables.sql
|
||||||
|
ADMIN_HTML_PATH: /app/admin.html
|
||||||
|
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||||
|
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||||
|
MINIO_DATA_DIR: /data
|
||||||
|
CRASH_STORAGE_ROOT: /data/crash_storage
|
||||||
|
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
||||||
|
MINIO_ENDPOINT: minio:9000
|
||||||
|
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required}
|
||||||
|
MINIO_AUTO_START: "false"
|
||||||
|
MINIO_SECURE: "false"
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
- ./runtime:/data
|
||||||
|
- ./keys:/run/secrets/update-keys:ro
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
minio-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 3s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 3
|
||||||
+960
-65
File diff suppressed because it is too large
Load Diff
+63
-6
@@ -7,39 +7,69 @@ import socket
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import urllib3
|
import urllib3
|
||||||
|
from urllib.parse import urlparse
|
||||||
from urllib3.util import Retry, Timeout
|
from urllib3.util import Retry, Timeout
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: bool = False) -> bool:
|
||||||
|
value = os.getenv(name)
|
||||||
|
return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
|
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT")
|
||||||
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
|
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY")
|
||||||
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
|
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY")
|
||||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET")
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET")
|
||||||
SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60)
|
SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60)
|
||||||
LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads"))
|
LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")).expanduser()
|
||||||
|
if not LOCAL_UPLOAD_ROOT.is_absolute():
|
||||||
|
LOCAL_UPLOAD_ROOT = BASE_DIR / LOCAL_UPLOAD_ROOT
|
||||||
LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE")
|
LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE")
|
||||||
|
MINIO_SECURE = env_bool("MINIO_SECURE")
|
||||||
|
MINIO_CONNECT_TIMEOUT_SEC = float(os.getenv("MINIO_CONNECT_TIMEOUT_SEC", "2"))
|
||||||
|
MINIO_READ_TIMEOUT_SEC = float(os.getenv("MINIO_READ_TIMEOUT_SEC", "5"))
|
||||||
|
MINIO_RETRY_TOTAL = int(os.getenv("MINIO_RETRY_TOTAL", "1"))
|
||||||
LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
http_client = urllib3.PoolManager(
|
http_client = urllib3.PoolManager(
|
||||||
timeout=Timeout(connect=2.0, read=5.0),
|
timeout=Timeout(connect=MINIO_CONNECT_TIMEOUT_SEC, read=MINIO_READ_TIMEOUT_SEC),
|
||||||
retries=Retry(total=1, connect=1, read=1, status=1, backoff_factor=0.2),
|
retries=Retry(total=MINIO_RETRY_TOTAL, connect=MINIO_RETRY_TOTAL,
|
||||||
|
read=MINIO_RETRY_TOTAL, status=MINIO_RETRY_TOTAL, backoff_factor=0.2),
|
||||||
)
|
)
|
||||||
|
|
||||||
mc = Minio(
|
mc = Minio(
|
||||||
MINIO_ENDPOINT,
|
MINIO_ENDPOINT,
|
||||||
access_key=MINIO_ACCESS_KEY,
|
access_key=MINIO_ACCESS_KEY,
|
||||||
secret_key=MINIO_SECRET_KEY,
|
secret_key=MINIO_SECRET_KEY,
|
||||||
secure=False,
|
secure=MINIO_SECURE,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
public_endpoint_raw = os.getenv("MINIO_PUBLIC_ENDPOINT", "").strip()
|
||||||
|
if public_endpoint_raw:
|
||||||
|
parsed_public_endpoint = urlparse(
|
||||||
|
public_endpoint_raw if "://" in public_endpoint_raw else f"http://{public_endpoint_raw}"
|
||||||
|
)
|
||||||
|
public_mc = Minio(
|
||||||
|
parsed_public_endpoint.netloc,
|
||||||
|
access_key=MINIO_ACCESS_KEY,
|
||||||
|
secret_key=MINIO_SECRET_KEY,
|
||||||
|
secure=parsed_public_endpoint.scheme == "https",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
public_mc = mc
|
||||||
|
|
||||||
|
|
||||||
def get_url(file_path: str) -> str:
|
def get_url(file_path: str) -> str:
|
||||||
bucket = MINIO_BUCKET
|
bucket = MINIO_BUCKET
|
||||||
full_object_path = file_path
|
full_object_path = file_path
|
||||||
try:
|
try:
|
||||||
mc.stat_object(bucket, full_object_path)
|
mc.stat_object(bucket, full_object_path)
|
||||||
url = mc.presigned_get_object(bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN))
|
url = public_mc.presigned_get_object(
|
||||||
|
bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN)
|
||||||
|
)
|
||||||
return url
|
return url
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(f"generate url error: {err}")
|
print(f"generate url error: {err}")
|
||||||
@@ -55,7 +85,8 @@ def get_url(file_path: str) -> str:
|
|||||||
host = '127.0.0.1'
|
host = '127.0.0.1'
|
||||||
port = os.getenv('SERVER_PORT') or '8000'
|
port = os.getenv('SERVER_PORT') or '8000'
|
||||||
return f"http://{host}:{port}/static/{file_path}"
|
return f"http://{host}:{port}/static/{file_path}"
|
||||||
return f"http://{MINIO_ENDPOINT}/{bucket}/{full_object_path}"
|
scheme = "https" if MINIO_SECURE else "http"
|
||||||
|
return f"{scheme}://{MINIO_ENDPOINT}/{bucket}/{full_object_path}"
|
||||||
|
|
||||||
|
|
||||||
def put_file(object_path: str, data: bytes, size: int):
|
def put_file(object_path: str, data: bytes, size: int):
|
||||||
@@ -91,3 +122,29 @@ def remove_prefix(prefix: str):
|
|||||||
shutil.rmtree(local_prefix)
|
shutil.rmtree(local_prefix)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"local remove_prefix error: {e}")
|
print(f"local remove_prefix error: {e}")
|
||||||
|
|
||||||
|
def iter_file(object_path: str, chunk_size: int = 1024 * 1024):
|
||||||
|
"""Stream an object without creating a second full local copy."""
|
||||||
|
response = None
|
||||||
|
emitted = 0
|
||||||
|
try:
|
||||||
|
response = mc.get_object(MINIO_BUCKET, object_path)
|
||||||
|
while True:
|
||||||
|
chunk = response.read(chunk_size)
|
||||||
|
if not chunk: break
|
||||||
|
emitted += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
|
except Exception as err:
|
||||||
|
if emitted:
|
||||||
|
raise RuntimeError(f"MinIO stream interrupted after {emitted} bytes: {object_path}") from err
|
||||||
|
print(f"MinIO stream fallback for {object_path}: {err}")
|
||||||
|
finally:
|
||||||
|
if response is not None:
|
||||||
|
response.close(); response.release_conn()
|
||||||
|
local_path = LOCAL_UPLOAD_ROOT / object_path
|
||||||
|
with open(local_path, "rb") as source:
|
||||||
|
while True:
|
||||||
|
chunk = source.read(chunk_size)
|
||||||
|
if not chunk: break
|
||||||
|
yield chunk
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
param(
|
||||||
|
[string]$Version = "0.1.0",
|
||||||
|
[string]$OutputDir = "$PSScriptRoot/dist/SimCAEServerDockerPackage",
|
||||||
|
[switch]$SkipBuild,
|
||||||
|
[switch]$SkipPull
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$serverDir = $PSScriptRoot
|
||||||
|
$projectRoot = (Resolve-Path (Join-Path $serverDir "..")).Path
|
||||||
|
$imageName = "simcae-update-server:$Version"
|
||||||
|
$minioImage = "minio/minio:RELEASE.2025-04-22T22-12-26Z"
|
||||||
|
$mcImage = "minio/mc:RELEASE.2025-04-16T18-13-26Z"
|
||||||
|
$packageDir = $OutputDir
|
||||||
|
$imagesDir = Join-Path $packageDir "images"
|
||||||
|
$keysDir = Join-Path $packageDir "keys"
|
||||||
|
$tarFile = Join-Path $imagesDir "simcae-server-all-images_$Version.tar"
|
||||||
|
|
||||||
|
function Require-File($path, $message) {
|
||||||
|
if (-not (Test-Path $path)) { throw $message }
|
||||||
|
}
|
||||||
|
|
||||||
|
Require-File (Join-Path $serverDir "Dockerfile") "server/Dockerfile is missing."
|
||||||
|
Require-File (Join-Path $serverDir "docker-compose.image.yml") "server/docker-compose.image.yml is missing."
|
||||||
|
Require-File (Join-Path $serverDir ".env.docker.example") "server/.env.docker.example is missing."
|
||||||
|
Require-File (Join-Path $serverDir "keys/manifest_private_key.pem") "server/keys/manifest_private_key.pem is missing."
|
||||||
|
Require-File (Join-Path $serverDir "keys/manifest_public_key.pem") "server/keys/manifest_public_key.pem is missing."
|
||||||
|
|
||||||
|
if (-not $SkipBuild) {
|
||||||
|
docker build -t $imageName -f (Join-Path $serverDir "Dockerfile") $projectRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipPull) {
|
||||||
|
docker pull $minioImage
|
||||||
|
docker pull $mcImage
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $packageDir) { Remove-Item $packageDir -Recurse -Force }
|
||||||
|
New-Item $imagesDir,$keysDir -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
|
Copy-Item (Join-Path $serverDir "docker-compose.image.yml") (Join-Path $packageDir "docker-compose.yml") -Force
|
||||||
|
Copy-Item (Join-Path $serverDir ".env.docker.example") (Join-Path $packageDir ".env.example") -Force
|
||||||
|
$readmeSource = Get-ChildItem $serverDir -File -Filter "*.md" | Where-Object {
|
||||||
|
$_.Name -like "*Docker*.md"
|
||||||
|
} | Select-Object -First 1
|
||||||
|
if ($readmeSource) {
|
||||||
|
Copy-Item $readmeSource.FullName (Join-Path $packageDir "README.md") -Force
|
||||||
|
}
|
||||||
|
Copy-Item (Join-Path $serverDir "keys/manifest_private_key.pem") (Join-Path $keysDir "manifest_private_key.pem") -Force
|
||||||
|
Copy-Item (Join-Path $serverDir "keys/manifest_public_key.pem") (Join-Path $keysDir "manifest_public_key.pem") -Force
|
||||||
|
|
||||||
|
$quickReadme = @"
|
||||||
|
# SimCAE Server Offline Docker Package
|
||||||
|
|
||||||
|
1. Copy this package to the target server and extract it.
|
||||||
|
2. Run: ./load-images.ps1
|
||||||
|
3. Edit .env and replace tokens, passwords, and SERVER_IP.
|
||||||
|
4. Run: docker compose up -d
|
||||||
|
5. Visit: http://SERVER_IP:8000/
|
||||||
|
|
||||||
|
Important: keys/manifest_private_key.pem is the server signing private key. Keep it secret.
|
||||||
|
"@
|
||||||
|
$readmePath = Join-Path $packageDir "README.md"
|
||||||
|
if (Test-Path $readmePath) {
|
||||||
|
$oldReadme = Get-Content $readmePath -Raw -Encoding UTF8
|
||||||
|
Set-Content $readmePath ($quickReadme + "`n---`n`n" + $oldReadme) -Encoding UTF8
|
||||||
|
} else {
|
||||||
|
Set-Content $readmePath $quickReadme -Encoding UTF8
|
||||||
|
}
|
||||||
|
|
||||||
|
@"
|
||||||
|
`$ErrorActionPreference = "Stop"
|
||||||
|
docker load -i .\images\simcae-server-all-images_$Version.tar
|
||||||
|
if (-not (Test-Path .\.env)) {
|
||||||
|
Copy-Item .\.env.example .\.env
|
||||||
|
Write-Host "Created .env from .env.example. Edit .env before starting services."
|
||||||
|
} else {
|
||||||
|
Write-Host ".env already exists. Keeping existing file."
|
||||||
|
}
|
||||||
|
Write-Host "Next: edit .env, then run: docker compose up -d"
|
||||||
|
"@ | Set-Content (Join-Path $packageDir "load-images.ps1") -Encoding UTF8
|
||||||
|
|
||||||
|
$envExamplePath = Join-Path $packageDir ".env.example"
|
||||||
|
$envText = Get-Content $envExamplePath -Raw -Encoding UTF8
|
||||||
|
$envText = $envText.Replace("SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0", "SIMCAE_UPDATE_SERVER_IMAGE=$imageName")
|
||||||
|
Set-Content $envExamplePath $envText -Encoding UTF8
|
||||||
|
|
||||||
|
docker save -o $tarFile $imageName $minioImage $mcImage
|
||||||
|
|
||||||
|
Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath "$packageDir.zip" -CompressionLevel Optimal -Force
|
||||||
|
|
||||||
|
Write-Host "Offline package directory: $packageDir"
|
||||||
|
Write-Host "Offline package zip: $packageDir.zip"
|
||||||
|
Write-Host "Images tar: $tarFile"
|
||||||
Executable
+292
@@ -0,0 +1,292 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="0.1.0"
|
||||||
|
OUTPUT_DIR=""
|
||||||
|
SKIP_BUILD=0
|
||||||
|
SKIP_PULL=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: ./package-offline-server.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--version VERSION Image/package version, default: 0.1.0
|
||||||
|
--output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage
|
||||||
|
--skip-build Do not build simcae-update-server image
|
||||||
|
--skip-pull Do not pull MinIO images
|
||||||
|
-h, --help Show this help
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:?--version requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--output-dir)
|
||||||
|
OUTPUT_DIR="${2:?--output-dir requires a value}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-build)
|
||||||
|
SKIP_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-pull)
|
||||||
|
SKIP_PULL=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
require_file() {
|
||||||
|
local path="$1"
|
||||||
|
local message="$2"
|
||||||
|
if [[ ! -e "$path" ]]; then
|
||||||
|
echo "$message" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local name="$1"
|
||||||
|
if ! command -v "$name" >/dev/null 2>&1; then
|
||||||
|
echo "Required command is missing: $name" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd docker
|
||||||
|
require_cmd tar
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
if [[ -z "$OUTPUT_DIR" ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage"
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_NAME="simcae-update-server:$VERSION"
|
||||||
|
MINIO_IMAGE="minio/minio:RELEASE.2025-04-22T22-12-26Z"
|
||||||
|
MC_IMAGE="minio/mc:RELEASE.2025-04-16T18-13-26Z"
|
||||||
|
PACKAGE_DIR="$OUTPUT_DIR"
|
||||||
|
IMAGES_DIR="$PACKAGE_DIR/images"
|
||||||
|
KEYS_DIR="$PACKAGE_DIR/keys"
|
||||||
|
TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar"
|
||||||
|
ARCHIVE_FILE="$PACKAGE_DIR.tar.gz"
|
||||||
|
|
||||||
|
require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing."
|
||||||
|
require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing."
|
||||||
|
require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing."
|
||||||
|
require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing."
|
||||||
|
require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing."
|
||||||
|
|
||||||
|
if [[ "$SKIP_BUILD" -eq 0 ]]; then
|
||||||
|
docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_PULL" -eq 0 ]]; then
|
||||||
|
docker pull "$MINIO_IMAGE"
|
||||||
|
docker pull "$MC_IMAGE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -rf "$PACKAGE_DIR"
|
||||||
|
mkdir -p "$IMAGES_DIR" "$KEYS_DIR"
|
||||||
|
|
||||||
|
cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
|
||||||
|
cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example"
|
||||||
|
|
||||||
|
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
|
||||||
|
# SimCAE 服务端 Docker 部署包说明
|
||||||
|
|
||||||
|
这个包已经包含服务端运行需要的 Docker 镜像、配置模板、签名密钥和编排文件。你的服务器无论能不能联网,都可以按下面流程部署。
|
||||||
|
|
||||||
|
## 一、这个包里面有什么
|
||||||
|
|
||||||
|
解压后目录结构如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SimCAEServerDockerPackage/
|
||||||
|
README.md 当前说明文档
|
||||||
|
docker-compose.yml 容器编排文件
|
||||||
|
.env.example 环境变量模板
|
||||||
|
load-images.sh 导入 Docker 镜像并创建 .env 的脚本
|
||||||
|
images/
|
||||||
|
simcae-server-all-images___VERSION__.tar 三个 Docker 镜像:api、minio、minio-init
|
||||||
|
keys/
|
||||||
|
manifest_private_key.pem 服务端 Manifest 签名私钥
|
||||||
|
manifest_public_key.pem 与客户端配套的验签公钥
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、你在服务器上怎么运行
|
||||||
|
|
||||||
|
先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf SimCAEServerDockerPackage.tar.gz
|
||||||
|
cd SimCAEServerDockerPackage
|
||||||
|
pwd
|
||||||
|
ls
|
||||||
|
```
|
||||||
|
|
||||||
|
你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
load-images.sh
|
||||||
|
images/
|
||||||
|
keys/
|
||||||
|
```
|
||||||
|
|
||||||
|
后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。
|
||||||
|
|
||||||
|
## 三、导入镜像并生成 .env
|
||||||
|
|
||||||
|
在 `SimCAEServerDockerPackage` 目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ./load-images.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
这个脚本会做两件事:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像
|
||||||
|
2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env
|
||||||
|
```
|
||||||
|
|
||||||
|
## 四、修改 .env
|
||||||
|
|
||||||
|
继续在 `SimCAEServerDockerPackage` 目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
正式部署时,建议同时修改这些默认值,避免所有环境共用同一套公开示例密码:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLIENT_API_TOKEN=客户端 API token,对应客户端 app_config.json 的 client_token
|
||||||
|
ADMIN_TOKEN=后台管理员 token,网页登录时填这个值
|
||||||
|
CRASH_REPORT_TOKEN=崩溃上传 token
|
||||||
|
CRASH_SYMBOL_TOKEN=符号上传 token
|
||||||
|
MINIO_ACCESS_KEY=MinIO 用户名
|
||||||
|
MINIO_SECRET_KEY=MinIO 密码
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你在网页里点击“更改令牌”,新令牌只会在当前服务进程中立即生效。为了让服务重启后仍然使用新令牌,请同步修改 `.env` 里的 `ADMIN_TOKEN`,然后执行 `docker compose restart api`。
|
||||||
|
|
||||||
|
`MINIO_PUBLIC_ENDPOINT` 必须是 Windows 客户端能访问到的地址。比如服务器 IP 是 `192.168.229.128`,就填:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、启动服务
|
||||||
|
|
||||||
|
确认你仍然在 `SimCAEServerDockerPackage` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwd
|
||||||
|
```
|
||||||
|
|
||||||
|
然后启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。
|
||||||
|
|
||||||
|
## 六、查看状态和日志
|
||||||
|
|
||||||
|
这些命令也都在 `SimCAEServerDockerPackage` 目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
正常情况下可以看到 `api`、`minio` 处于 running/healthy 状态。
|
||||||
|
|
||||||
|
## 七、访问地址
|
||||||
|
|
||||||
|
把下面的 `你的服务器IP` 换成真实服务器 IP:
|
||||||
|
|
||||||
|
```text
|
||||||
|
后台/API: http://你的服务器IP:8000/
|
||||||
|
MinIO 控制台: http://你的服务器IP:9001/
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务器开启防火墙,至少放行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
8000 后台/API
|
||||||
|
9000 客户端下载升级文件
|
||||||
|
9001 MinIO 控制台,可选
|
||||||
|
```
|
||||||
|
|
||||||
|
## 八、重要提醒
|
||||||
|
|
||||||
|
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
|
||||||
|
EOF_README
|
||||||
|
|
||||||
|
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
version = sys.argv[2]
|
||||||
|
text = path.read_text(encoding='utf-8').replace('__VERSION__', version)
|
||||||
|
path.write_text(text, encoding='utf-8')
|
||||||
|
PYREADME
|
||||||
|
|
||||||
|
cat > "$PACKAGE_DIR/load-images.sh" <<EOF
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker load -i ./images/simcae-server-all-images_$VERSION.tar
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Created .env from .env.example. Edit .env before starting services."
|
||||||
|
else
|
||||||
|
echo ".env already exists. Keeping existing file."
|
||||||
|
fi
|
||||||
|
echo "Next: edit .env, then run: docker compose up -d"
|
||||||
|
EOF
|
||||||
|
chmod +x "$PACKAGE_DIR/load-images.sh"
|
||||||
|
|
||||||
|
python3 - "$PACKAGE_DIR/.env.example" "$IMAGE_NAME" <<'PYENV'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
image = sys.argv[2]
|
||||||
|
text = path.read_text(encoding='utf-8')
|
||||||
|
text = text.replace('SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0', f'SIMCAE_UPDATE_SERVER_IMAGE={image}')
|
||||||
|
path.write_text(text, encoding='utf-8')
|
||||||
|
PYENV
|
||||||
|
|
||||||
|
docker save -o "$TAR_FILE" "$IMAGE_NAME" "$MINIO_IMAGE" "$MC_IMAGE"
|
||||||
|
|
||||||
|
rm -f "$ARCHIVE_FILE"
|
||||||
|
tar -czf "$ARCHIVE_FILE" -C "$(dirname "$PACKAGE_DIR")" "$(basename "$PACKAGE_DIR")"
|
||||||
|
|
||||||
|
echo "Offline package directory: $PACKAGE_DIR"
|
||||||
|
echo "Offline package archive: $ARCHIVE_FILE"
|
||||||
|
echo "Images tar: $TAR_FILE"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
cryptography==49.0.0
|
||||||
|
fastapi==0.138.0
|
||||||
|
minio==7.2.20
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.32
|
||||||
|
uvicorn==0.49.0
|
||||||
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS versions (
|
|||||||
channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版
|
channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版
|
||||||
version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2
|
version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2
|
||||||
latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版
|
latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版
|
||||||
|
client_protocol INTEGER NOT NULL DEFAULT 1, -- 该版本客户端支持的更新/策略协议版本
|
||||||
create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间
|
create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间
|
||||||
UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本
|
UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本
|
||||||
);
|
);
|
||||||
@@ -63,3 +64,124 @@ CREATE TABLE IF NOT EXISTS version_policies (
|
|||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
UNIQUE(app_id, channel)
|
UNIQUE(app_id, channel)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 7. RSA 签发的客户端设备身份
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL UNIQUE, app_id TEXT NOT NULL,
|
||||||
|
installation_id TEXT NOT NULL, machine_hash TEXT NOT NULL DEFAULT '', credential_seq INTEGER NOT NULL DEFAULT 1,
|
||||||
|
disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), last_ip TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE(app_id, installation_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 8. 软件授权及其设备占用关系
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE,
|
||||||
|
customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable',
|
||||||
|
max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS license_devices (
|
||||||
|
license_id TEXT NOT NULL, device_id TEXT NOT NULL UNIQUE, bound_at TEXT DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY(license_id,device_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 9. 每个应用独立维护的动态发布渠道
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, channel_code TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(app_id, channel_code)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- 10. 文件下载授权与客户端完成结果
|
||||||
|
CREATE TABLE IF NOT EXISTS download_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, license_id TEXT NOT NULL,
|
||||||
|
app_id TEXT NOT NULL, version TEXT NOT NULL, channel_code TEXT NOT NULL, file_path TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL DEFAULT 0, result TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_logs_created ON download_logs(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,created_at);
|
||||||
|
|
||||||
|
-- 11. 管理后台写操作审计
|
||||||
|
CREATE TABLE IF NOT EXISTS admin_audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL,
|
||||||
|
status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at);
|
||||||
|
|
||||||
|
|
||||||
|
-- 12. SimCAE 崩溃报告原始数据索引
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
report_id TEXT NOT NULL UNIQUE,
|
||||||
|
client_report_id TEXT NOT NULL UNIQUE,
|
||||||
|
content_hash TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'stored',
|
||||||
|
symbolication_status TEXT NOT NULL DEFAULT 'not_started',
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
app_version TEXT NOT NULL,
|
||||||
|
git_commit TEXT NOT NULL,
|
||||||
|
build_type TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
|
exception_code TEXT NOT NULL DEFAULT '',
|
||||||
|
crash_time_utc TEXT NOT NULL,
|
||||||
|
received_at_utc TEXT NOT NULL,
|
||||||
|
remote_address TEXT NOT NULL DEFAULT '',
|
||||||
|
content_length INTEGER NOT NULL DEFAULT 0,
|
||||||
|
metadata_sha256 TEXT NOT NULL,
|
||||||
|
metadata_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
minidump_sha256 TEXT NOT NULL,
|
||||||
|
minidump_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
attachments_sha256 TEXT NOT NULL DEFAULT '',
|
||||||
|
attachments_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL,
|
||||||
|
server_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_reports_build ON crash_reports(product,app_version,git_commit);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_reports_exception ON crash_reports(exception_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_reports_received ON crash_reports(received_at_utc);
|
||||||
|
|
||||||
|
-- 13. SimCAE 符号包上传索引
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_symbol_uploads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
symbol_upload_id TEXT NOT NULL UNIQUE,
|
||||||
|
identity_hash TEXT NOT NULL UNIQUE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'stored',
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
app_version TEXT NOT NULL,
|
||||||
|
git_commit TEXT NOT NULL,
|
||||||
|
build_type TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
toolchain TEXT NOT NULL,
|
||||||
|
created_at_utc TEXT NOT NULL,
|
||||||
|
received_at_utc TEXT NOT NULL,
|
||||||
|
metadata_sha256 TEXT NOT NULL,
|
||||||
|
metadata_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
symbols_sha256 TEXT NOT NULL,
|
||||||
|
symbols_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
metadata_json TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_symbols_build ON crash_symbol_uploads(product,app_version,git_commit,build_type,platform);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_symbols_received ON crash_symbol_uploads(received_at_utc);
|
||||||
|
|
||||||
|
-- 14. 崩溃原始文件访问审计
|
||||||
|
CREATE TABLE IF NOT EXISTS crash_file_access_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
report_id TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
actor_hash TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crash_file_access_report ON crash_file_access_logs(report_id,created_at);
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# 服务端 Docker 镜像交付说明
|
||||||
|
|
||||||
|
这份说明用于把 SimCAE 自动升级/崩溃上报服务端交付给开发组或部署人员。
|
||||||
|
|
||||||
|
## 1. 要不要给开发组发 Docker 镜像
|
||||||
|
|
||||||
|
分两种情况:
|
||||||
|
|
||||||
|
- 如果开发组只负责接入客户端 SDK,通常不需要给他们镜像,只需要给一个已经部署好的测试服务地址、token、License 和 SDK 文档。
|
||||||
|
- 如果开发组需要本地完整联调,建议给他们一个服务端 Docker 部署包。部署包不要只有一个镜像,还要包含 docker-compose.image.yml、.env 示例、keys 目录说明。
|
||||||
|
|
||||||
|
## 2. 服务端包含哪些容器
|
||||||
|
|
||||||
|
当前服务端不是单容器,而是三个服务:
|
||||||
|
|
||||||
|
- api:FastAPI 服务,提供升级接口、后台页面、Manifest 签名、崩溃上报接口。
|
||||||
|
- minio:对象存储,用于保存版本文件。
|
||||||
|
- minio-init:启动时自动创建 MinIO bucket。
|
||||||
|
|
||||||
|
所以交付时推荐给 Docker Compose 部署包。
|
||||||
|
|
||||||
|
## 3. 构建服务端镜像
|
||||||
|
|
||||||
|
在项目根目录执行,注意 Dockerfile 的构建上下文必须是项目根目录,因为镜像会复制 server 代码和 client/admin.html。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\admin\Desktop
|
||||||
|
|
||||||
|
docker build `
|
||||||
|
-t simcae-update-server:0.1.0 `
|
||||||
|
-f server\Dockerfile `
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
验证镜像:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker images simcae-update-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 导出镜像给别人
|
||||||
|
|
||||||
|
如果接收方机器可以访问 Docker Hub,只导出 api 镜像即可:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker save `
|
||||||
|
-o simcae-update-server_0.1.0.tar `
|
||||||
|
simcae-update-server:0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
如果接收方机器不能联网,建议把 api、minio、minio-init 使用的三个镜像一起导出:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||||
|
docker pull minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
|
||||||
|
docker save `
|
||||||
|
-o simcae-server-all-images_0.1.0.tar `
|
||||||
|
simcae-update-server:0.1.0 `
|
||||||
|
minio/minio:RELEASE.2025-04-22T22-12-26Z `
|
||||||
|
minio/mc:RELEASE.2025-04-16T18-13-26Z
|
||||||
|
```
|
||||||
|
|
||||||
|
建议交付目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SimCAEServerDockerPackage/
|
||||||
|
simcae-update-server_0.1.0.tar
|
||||||
|
docker-compose.image.yml
|
||||||
|
.env.example
|
||||||
|
keys/
|
||||||
|
manifest_private_key.pem
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:manifest_private_key.pem 是服务端签 Manifest 的私钥。客户端 SDK 中的 manifest_public_key.pem 必须和它匹配。
|
||||||
|
|
||||||
|
|
||||||
|
## 4.1 推荐:在 Ubuntu 上使用离线打包脚本
|
||||||
|
|
||||||
|
如果接收方服务器不能联网,推荐直接使用 Ubuntu/bash 脚本生成完整离线包。脚本会完成:构建 api 镜像、拉取 MinIO 镜像、导出三张镜像、复制 docker-compose.yml、复制 .env.example、复制 keys、生成快速启动说明,并压缩成 tar.gz。
|
||||||
|
|
||||||
|
在当前 Ubuntu 机器上执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/project/server
|
||||||
|
|
||||||
|
bash ./package-offline-server.sh \
|
||||||
|
--version 0.1.0 \
|
||||||
|
--output-dir ./dist/SimCAEServerDockerPackage
|
||||||
|
```
|
||||||
|
|
||||||
|
生成结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server/dist/SimCAEServerDockerPackage/
|
||||||
|
images/
|
||||||
|
simcae-server-all-images_0.1.0.tar
|
||||||
|
keys/
|
||||||
|
manifest_private_key.pem
|
||||||
|
manifest_public_key.pem
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example
|
||||||
|
README.md
|
||||||
|
QUICK_START.md
|
||||||
|
load-images.sh
|
||||||
|
|
||||||
|
server/dist/SimCAEServerDockerPackage.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `SimCAEServerDockerPackage.tar.gz` 拷贝到离线服务器即可。
|
||||||
|
|
||||||
|
如果是在 Windows 机器上打包,也可以使用 `package-offline-server.ps1`,但 Ubuntu 服务端主流程建议使用 `package-offline-server.sh`。
|
||||||
|
|
||||||
|
## 5. 离线 Ubuntu 服务器如何运行
|
||||||
|
|
||||||
|
接收方服务器需要提前安装好 Docker Engine 和 Docker Compose plugin。离线包拷过去后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf SimCAEServerDockerPackage.tar.gz
|
||||||
|
cd SimCAEServerDockerPackage
|
||||||
|
|
||||||
|
bash ./load-images.sh
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
正式部署时,建议同时修改这些默认值,避免所有环境共用同一套公开示例密码:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLIENT_API_TOKEN=客户端 API token,对应客户端 app_config.json 的 client_token
|
||||||
|
ADMIN_TOKEN=后台管理员 token,网页登录时填这个值
|
||||||
|
CRASH_REPORT_TOKEN=崩溃上传 token
|
||||||
|
CRASH_SYMBOL_TOKEN=符号包上传 token
|
||||||
|
MINIO_ACCESS_KEY=MinIO 用户名
|
||||||
|
MINIO_SECRET_KEY=MinIO 密码
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你在网页里点击“更改令牌”,新令牌只会在当前服务进程中立即生效。为了让服务重启后仍然使用新令牌,请同步修改 `.env` 里的 `ADMIN_TOKEN`,然后执行 `docker compose restart api`。
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
查看日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
后台/API: http://服务器IP:8000/
|
||||||
|
MinIO 控制台: http://服务器IP:9001/
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务器开启了防火墙,至少放行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
8000 后台/API
|
||||||
|
9000 客户端下载升级文件
|
||||||
|
9001 MinIO 控制台,可按需限制访问
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 客户端 SDK 需要哪些服务端信息
|
||||||
|
|
||||||
|
发给客户端开发组的信息通常是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
api_base_url=http://服务器IP:8000
|
||||||
|
client_token=CLIENT_API_TOKEN 的值
|
||||||
|
license_key=后台创建的 License
|
||||||
|
manifest_public_key.pem=与服务端 manifest_private_key.pem 匹配的公钥
|
||||||
|
crash_report_token=CRASH_REPORT_TOKEN 的值,如果接入崩溃上报
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 生产部署提醒
|
||||||
|
|
||||||
|
- 不要把生产私钥和 token 发到无关人员手里。
|
||||||
|
- 正式环境需要备份 runtime、minio_data、keys。
|
||||||
|
- 对外开放端口至少包括 8000 和 9000;9001 是 MinIO 控制台,生产环境可限制访问。
|
||||||
|
- 修改服务端签名私钥后,必须重新生成客户端 manifest_public_key.pem,并重新打 SDK/客户端包。
|
||||||
Reference in New Issue
Block a user