chore: prepare repository for submodule split
This commit is contained in:
+1
-1
@@ -5,4 +5,4 @@
|
|||||||
!server/db.py
|
!server/db.py
|
||||||
!server/minio_tool.py
|
!server/minio_tool.py
|
||||||
!server/tables.sql
|
!server/tables.sql
|
||||||
!client/admin.html
|
!server/admin.html
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ Desktop.ini
|
|||||||
*.7z
|
*.7z
|
||||||
*.rar
|
*.rar
|
||||||
|
|
||||||
|
# Requirement / handover documents kept outside Git
|
||||||
|
client/*.pdf
|
||||||
|
client/*.doc
|
||||||
|
client/*.docx
|
||||||
|
server/*.doc
|
||||||
|
server/*.docx
|
||||||
|
server/*交付说明*.md
|
||||||
|
|
||||||
# C/C++ generated artifacts outside build directories
|
# C/C++ generated artifacts outside build directories
|
||||||
*.obj
|
*.obj
|
||||||
*.o
|
*.o
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Operating systems and editors
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Private requirement / handover documents
|
||||||
|
*.pdf
|
||||||
|
*.doc
|
||||||
|
*.docx
|
||||||
|
|
||||||
|
# C/C++ generated artifacts
|
||||||
|
*.obj
|
||||||
|
*.o
|
||||||
|
*.pdb
|
||||||
|
*.ilk
|
||||||
|
*.idb
|
||||||
|
*.tlog
|
||||||
|
*.lastbuildstate
|
||||||
|
*.exp
|
||||||
|
*.lib
|
||||||
|
*.dll
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
cmake-build-*/
|
||||||
|
.cmake/
|
||||||
|
CMakeFiles/
|
||||||
|
CMakeCache.txt
|
||||||
|
CMakeSettings.json
|
||||||
|
CMakeUserPresets.json
|
||||||
|
Testing/
|
||||||
|
|
||||||
|
# Third-party/business binary drops and generated packages
|
||||||
|
App/
|
||||||
|
dist/
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
|
*.7z
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# Local runtime state and credentials
|
||||||
|
config/app_config.json
|
||||||
|
config/client_identity.dat
|
||||||
|
config/local_state.json
|
||||||
|
config/version_policy.dat
|
||||||
|
config/*.local.json
|
||||||
|
config/*private*.pem
|
||||||
|
config/*private*.key
|
||||||
|
update/
|
||||||
|
update_temp/
|
||||||
@@ -28,6 +28,40 @@ QString ConfigHelper::configPath() const
|
|||||||
return m_configPath;
|
return m_configPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::installRoot() const
|
||||||
|
{
|
||||||
|
QString relativeRoot = getValue("Runtime", "install_root").trimmed();
|
||||||
|
if (relativeRoot.isEmpty())
|
||||||
|
relativeRoot = ".";
|
||||||
|
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::runtimeRoot() const
|
||||||
|
{
|
||||||
|
return QDir::cleanPath(QApplication::applicationDirPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::updateRoot() const
|
||||||
|
{
|
||||||
|
return QDir(runtimeRoot()).filePath("update");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::runtimeRelativePath() const
|
||||||
|
{
|
||||||
|
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
|
||||||
|
relative = QDir::cleanPath(relative);
|
||||||
|
if (relative == ".")
|
||||||
|
return QString();
|
||||||
|
while (relative.startsWith("./"))
|
||||||
|
relative = relative.mid(2);
|
||||||
|
return relative.trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::lastError() const
|
||||||
|
{
|
||||||
|
return m_error;
|
||||||
|
}
|
||||||
|
|
||||||
QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
||||||
{
|
{
|
||||||
Q_UNUSED(section);
|
Q_UNUSED(section);
|
||||||
@@ -46,33 +80,56 @@ QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
|||||||
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
|
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
|
||||||
{
|
{
|
||||||
Q_UNUSED(section);
|
Q_UNUSED(section);
|
||||||
|
m_error.clear();
|
||||||
|
|
||||||
QFile input(m_configPath);
|
QFile input(m_configPath);
|
||||||
QJsonObject config;
|
QJsonObject config;
|
||||||
if (input.exists())
|
if (input.exists())
|
||||||
{
|
{
|
||||||
if (!input.open(QIODevice::ReadOnly))
|
if (!input.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
QJsonParseError parseError;
|
QJsonParseError parseError;
|
||||||
const QByteArray raw = input.readAll();
|
const QByteArray raw = input.readAll();
|
||||||
input.close();
|
input.close();
|
||||||
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||||
|
{
|
||||||
|
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
config = document.object();
|
config = document.object();
|
||||||
}
|
}
|
||||||
|
|
||||||
config.insert(key, value);
|
config.insert(key, value);
|
||||||
QDir().mkpath(QFileInfo(m_configPath).path());
|
QDir dir(QFileInfo(m_configPath).path());
|
||||||
|
if (!dir.exists() && !dir.mkpath("."))
|
||||||
|
{
|
||||||
|
m_error = QString("Cannot create config directory: %1").arg(dir.path());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QSaveFile output(m_configPath);
|
QSaveFile output(m_configPath);
|
||||||
if (!output.open(QIODevice::WriteOnly))
|
if (!output.open(QIODevice::WriteOnly))
|
||||||
|
{
|
||||||
|
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||||
if (output.write(payload) != payload.size())
|
if (output.write(payload) != payload.size())
|
||||||
{
|
{
|
||||||
|
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
|
||||||
output.cancelWriting();
|
output.cancelWriting();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return output.commit();
|
if (!output.commit())
|
||||||
|
{
|
||||||
|
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||||
@@ -103,6 +160,7 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
|
|||||||
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", "install_root", ".");
|
||||||
copyText("Runtime", "main_executable", "MainApp.exe");
|
copyText("Runtime", "main_executable", "MainApp.exe");
|
||||||
copyText("Runtime", "launcher_executable", "Launcher.exe");
|
copyText("Runtime", "launcher_executable", "Launcher.exe");
|
||||||
copyText("Runtime", "updater_executable", "Updater.exe");
|
copyText("Runtime", "updater_executable", "Updater.exe");
|
||||||
|
|||||||
@@ -8,9 +8,15 @@ public:
|
|||||||
QString getValue(const QString& section, const QString& key) const;
|
QString getValue(const QString& section, const QString& key) const;
|
||||||
bool setValue(const QString& section, const QString& key, const QString& value);
|
bool setValue(const QString& section, const QString& key, const QString& value);
|
||||||
QString configPath() const;
|
QString configPath() const;
|
||||||
|
QString installRoot() const;
|
||||||
|
QString runtimeRoot() const;
|
||||||
|
QString updateRoot() const;
|
||||||
|
QString runtimeRelativePath() const;
|
||||||
|
QString lastError() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ConfigHelper();
|
ConfigHelper();
|
||||||
bool migrateLegacyIniIfNeeded();
|
bool migrateLegacyIniIfNeeded();
|
||||||
QString m_configPath;
|
QString m_configPath;
|
||||||
|
QString m_error;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QSysInfo>
|
#include <QSysInfo>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
#include <QTimer>
|
||||||
#ifdef HAVE_OPENSSL
|
#ifdef HAVE_OPENSSL
|
||||||
#include <openssl/evp.h>
|
#include <openssl/evp.h>
|
||||||
#include <openssl/pem.h>
|
#include <openssl/pem.h>
|
||||||
@@ -35,7 +36,14 @@ bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& cha
|
|||||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
|
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){
|
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;}
|
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;}}
|
const QString trimmedBase=base.trimmed();
|
||||||
|
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
|
||||||
|
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
|
||||||
|
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
|
||||||
|
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
|
||||||
|
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
|
||||||
|
ConfigHelper& config=ConfigHelper::instance();
|
||||||
|
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
|
||||||
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
|
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;
|
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||||
@@ -27,9 +28,22 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
|||||||
|
|
||||||
QNetworkReply* reply = manager->post(req, data);
|
QNetworkReply* reply = manager->post(req, data);
|
||||||
QEventLoop loop;
|
QEventLoop loop;
|
||||||
|
bool timeoutOk = false;
|
||||||
|
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
|
||||||
|
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
|
||||||
|
QTimer timer;
|
||||||
|
timer.setSingleShot(true);
|
||||||
|
QObject::connect(&timer, &QTimer::timeout, [&]() {
|
||||||
|
if (reply && reply->isRunning()) {
|
||||||
|
qDebug() << "Request timeout, abort:" << url;
|
||||||
|
reply->abort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
|
timer.start(timeoutMs);
|
||||||
loop.exec();
|
loop.exec();
|
||||||
|
timer.stop();
|
||||||
|
|
||||||
int retCode = 0;
|
int retCode = 0;
|
||||||
QJsonObject retObj;
|
QJsonObject retObj;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include "IntegrityHelper.h"
|
#include "IntegrityHelper.h"
|
||||||
|
#include "ConfigHelper.h"
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
@@ -28,10 +29,19 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const
|
|||||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||||
{
|
{
|
||||||
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||||
static const QSet<QString> protectedPaths{
|
QSet<QString> protectedPaths{
|
||||||
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||||
"config/client_identity.dat", "config/version_policy.dat"
|
"config/client_identity.dat", "config/version_policy.dat"
|
||||||
};
|
};
|
||||||
|
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||||
|
if (!runtimePrefix.isEmpty()) {
|
||||||
|
const QStringList runtimeProtected{
|
||||||
|
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||||
|
"config/client_identity.dat", "config/version_policy.dat"
|
||||||
|
};
|
||||||
|
for (const QString& protectedPath : runtimeProtected)
|
||||||
|
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
||||||
|
}
|
||||||
return protectedPaths.contains(p);
|
return protectedPaths.contains(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +59,10 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
|||||||
#ifndef HAVE_OPENSSL
|
#ifndef HAVE_OPENSSL
|
||||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||||
#else
|
#else
|
||||||
QFile keyFile(QDir(m_installDir).filePath("config/manifest_public_key.pem"));
|
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
|
||||||
|
if (!QFile::exists(keyPath))
|
||||||
|
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
|
||||||
|
QFile keyFile(keyPath);
|
||||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
|
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
|
||||||
const QByteArray keyData = keyFile.readAll();
|
const QByteArray keyData = keyFile.readAll();
|
||||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||||
@@ -72,8 +85,12 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
|||||||
const QString& version)
|
const QString& version)
|
||||||
{
|
{
|
||||||
m_error.clear();
|
m_error.clear();
|
||||||
const QString cachePath = QDir(m_installDir).filePath(
|
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
|
||||||
|
"manifest_cache/manifest_" + version + ".json");
|
||||||
|
const QString legacyCachePath = QDir(m_installDir).filePath(
|
||||||
"update/manifest_cache/manifest_" + version + ".json");
|
"update/manifest_cache/manifest_" + version + ".json");
|
||||||
|
if (!QFile::exists(cachePath))
|
||||||
|
cachePath = legacyCachePath;
|
||||||
QFile cache(cachePath);
|
QFile cache(cachePath);
|
||||||
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
|
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
|
||||||
QJsonParseError wrapperError;
|
QJsonParseError wrapperError;
|
||||||
@@ -121,8 +138,12 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
|||||||
const QString fullPath = it.next();
|
const QString fullPath = it.next();
|
||||||
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
||||||
const QString folded = relative.toCaseFolded();
|
const QString folded = relative.toCaseFolded();
|
||||||
|
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||||
|
const bool runtimeWorkDir = !runtimePrefix.isEmpty()
|
||||||
|
&& (folded.startsWith(runtimePrefix + "/update/")
|
||||||
|
|| folded.startsWith(runtimePrefix + "/update_temp/"));
|
||||||
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
||||||
|| runtimeProtectedPath(relative)) continue;
|
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
||||||
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
|
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
|
||||||
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
|
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
|
||||||
m_error = "undeclared executable or plugin: " + relative; return false;
|
m_error = "undeclared executable or plugin: " + relative; return false;
|
||||||
|
|||||||
+88
-11
@@ -4,6 +4,8 @@
|
|||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QProgressDialog>
|
#include <QProgressDialog>
|
||||||
|
#include <QInputDialog>
|
||||||
|
#include <QLineEdit>
|
||||||
#include <QTextCodec>
|
#include <QTextCodec>
|
||||||
#include "UpdateLogic.h"
|
#include "UpdateLogic.h"
|
||||||
#include "../Common/ConfigHelper.h"
|
#include "../Common/ConfigHelper.h"
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
#include "../Common/DeviceIdentityHelper.h"
|
#include "../Common/DeviceIdentityHelper.h"
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
|
#include <QDir>
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
@@ -30,16 +33,81 @@ int main(int argc, char* argv[])
|
|||||||
progress.show();
|
progress.show();
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
|
|
||||||
|
const QString appDir = QApplication::applicationDirPath();
|
||||||
ConfigHelper& config = ConfigHelper::instance();
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
DeviceIdentityHelper identity(QApplication::applicationDirPath());
|
const auto isLicenseError = [](const QString& errorText) {
|
||||||
if (!identity.ensureIssued(config.getValue("Server", "api_base_url"),
|
const QString text = errorText.toLower();
|
||||||
config.getValue("Server", "client_token"),
|
return text.contains("license")
|
||||||
config.getValue("App", "app_id"),
|
|| errorText.contains("授权")
|
||||||
config.getValue("App", "channel"),
|
|| errorText.contains("过期")
|
||||||
config.getValue("License", "license_key"))) {
|
|| errorText.contains("设备数量已达到上限")
|
||||||
progress.close();
|
|| errorText.contains("已绑定其他授权");
|
||||||
QMessageBox::critical(nullptr, "设备身份验证失败", identity.errorString());
|
};
|
||||||
return -1;
|
const auto clearDeviceCredential = [&]() {
|
||||||
|
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
|
||||||
|
config.setValue("Update", "device_id", QString());
|
||||||
|
};
|
||||||
|
QString licenseKey = config.getValue("License", "license_key").trimmed();
|
||||||
|
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
|
||||||
|
QString promptReason = reason;
|
||||||
|
while (true) {
|
||||||
|
progress.close();
|
||||||
|
bool accepted = false;
|
||||||
|
const QString appName = config.getValue("App", "app_name").trimmed();
|
||||||
|
const QString prompt = promptReason.trimmed().isEmpty()
|
||||||
|
? QString("请输入%1授权 License:").arg(appName.isEmpty() ? "软件" : appName)
|
||||||
|
: QString("%1\n\n请重新输入%2授权 License:").arg(promptReason, appName.isEmpty() ? "软件" : appName);
|
||||||
|
licenseKey = QInputDialog::getText(
|
||||||
|
nullptr,
|
||||||
|
"输入 License",
|
||||||
|
prompt,
|
||||||
|
QLineEdit::Normal,
|
||||||
|
QString(),
|
||||||
|
&accepted
|
||||||
|
).trimmed();
|
||||||
|
if (!accepted) {
|
||||||
|
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。");
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
if (licenseKey.isEmpty()) {
|
||||||
|
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。");
|
||||||
|
promptReason.clear();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!config.setValue("License", "license_key", licenseKey)) {
|
||||||
|
QMessageBox::critical(nullptr, "保存 License 失败",
|
||||||
|
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError()));
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
progress.show();
|
||||||
|
progress.setLabelText("正在验证 License...");
|
||||||
|
QApplication::processEvents();
|
||||||
|
return licenseKey;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (licenseKey.isEmpty()) {
|
||||||
|
licenseKey = promptAndSaveLicense(QString());
|
||||||
|
if (licenseKey.isEmpty()) return 0;
|
||||||
|
}
|
||||||
|
DeviceIdentityHelper identity(appDir);
|
||||||
|
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
|
||||||
|
config.getValue("Server", "client_token"),
|
||||||
|
config.getValue("App", "app_id"),
|
||||||
|
config.getValue("App", "channel"),
|
||||||
|
licenseKey)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const QString error = identity.errorString();
|
||||||
|
if (!isLicenseError(error)) {
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::critical(nullptr, "设备身份验证失败", error);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
clearDeviceCredential();
|
||||||
|
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error));
|
||||||
|
if (licenseKey.isEmpty()) return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateLogic logic;
|
UpdateLogic logic;
|
||||||
@@ -57,13 +125,22 @@ int main(int argc, char* argv[])
|
|||||||
progress.close();
|
progress.close();
|
||||||
const QJsonValue detail = response.value("detail");
|
const QJsonValue detail = response.value("detail");
|
||||||
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
|
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
|
||||||
QMessageBox::critical(nullptr, "授权被拒绝", message.isEmpty() ? "设备或 License 授权无效。" : message);
|
const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message;
|
||||||
|
if (isLicenseError(displayMessage)) {
|
||||||
|
clearDeviceCredential();
|
||||||
|
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage));
|
||||||
|
if (!newLicense.isEmpty()) {
|
||||||
|
progress.close();
|
||||||
|
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。");
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage);
|
||||||
return -1;
|
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 auto configuredName = [&](const QString& key, const QString& fallback) {
|
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||||
const QString value = config.getValue("Runtime", key).trimmed();
|
const QString value = config.getValue("Runtime", key).trimmed();
|
||||||
return value.isEmpty() ? fallback : value;
|
return value.isEmpty() ? fallback : value;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
QString appDir = QApplication::applicationDirPath();
|
QString appDir = QApplication::applicationDirPath();
|
||||||
|
const QString installRoot = config.installRoot();
|
||||||
DeviceIdentityHelper identity(appDir);
|
DeviceIdentityHelper identity(appDir);
|
||||||
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
|
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
|
||||||
{
|
{
|
||||||
@@ -89,7 +90,7 @@ int main(int argc, char* argv[])
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
IntegrityHelper integrity(appDir);
|
IntegrityHelper integrity(installRoot);
|
||||||
if (!integrity.verifyInstalledVersion(
|
if (!integrity.verifyInstalledVersion(
|
||||||
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
||||||
config.getValue("App", "current_version")))
|
config.getValue("App", "current_version")))
|
||||||
|
|||||||
+69
-10
@@ -17,12 +17,63 @@ SDK 核心程序:
|
|||||||
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。
|
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。
|
||||||
2. 创建或确认渠道,例如 `stable`。
|
2. 创建或确认渠道,例如 `stable`。
|
||||||
3. 创建 License,把生成的 `license_key` 填到客户端配置。
|
3. 创建 License,把生成的 `license_key` 填到客户端配置。
|
||||||
4. 把业务程序和依赖 DLL 放到同一个发布目录。
|
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`。
|
||||||
5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 和运行时 DLL 放到该目录。
|
5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。
|
||||||
6. 把 `config/app_config.example.json` 复制成 `config/app_config.json` 并修改字段。
|
6. 把 `config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
|
||||||
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
|
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
|
||||||
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
|
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
|
||||||
|
|
||||||
|
## 安装目录写权限要求
|
||||||
|
|
||||||
|
当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config/app_config.json
|
||||||
|
config/client_identity.dat
|
||||||
|
config/local_state.json
|
||||||
|
config/version_policy.dat
|
||||||
|
update/
|
||||||
|
update_temp/
|
||||||
|
```
|
||||||
|
|
||||||
|
所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`。
|
||||||
|
|
||||||
|
推荐联调目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
D:\SimCAE_Release\
|
||||||
|
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
|
||||||
|
```
|
||||||
|
|
||||||
|
如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。
|
||||||
|
|
||||||
|
## SimCAE 目录结构建议
|
||||||
|
|
||||||
|
SimCAE 当前安装目录是根目录下有 `bin/`、`Licenses/`、`installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SimCAE/
|
||||||
|
bin/
|
||||||
|
Launcher.exe
|
||||||
|
Updater.exe
|
||||||
|
Bootstrap.exe
|
||||||
|
SimCAE.exe
|
||||||
|
config/
|
||||||
|
app_config.json
|
||||||
|
manifest_public_key.pem
|
||||||
|
update/
|
||||||
|
manifest_cache/
|
||||||
|
Qt5Core.dll
|
||||||
|
...
|
||||||
|
Licenses/
|
||||||
|
installerResources/
|
||||||
|
maintenancetool.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root` 填 `..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`。
|
||||||
|
|
||||||
|
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll`、`platforms/`、`imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。
|
||||||
|
|
||||||
## app_config.json 关键字段
|
## app_config.json 关键字段
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -39,6 +90,7 @@ SDK 核心程序:
|
|||||||
"request_timeout_ms": "5000",
|
"request_timeout_ms": "5000",
|
||||||
"temp_folder": "update_temp",
|
"temp_folder": "update_temp",
|
||||||
"device_id": "",
|
"device_id": "",
|
||||||
|
"install_root": "..",
|
||||||
"main_executable": "SimCAE.exe",
|
"main_executable": "SimCAE.exe",
|
||||||
"launcher_executable": "Launcher.exe",
|
"launcher_executable": "Launcher.exe",
|
||||||
"updater_executable": "Updater.exe",
|
"updater_executable": "Updater.exe",
|
||||||
@@ -59,7 +111,8 @@ SDK 核心程序:
|
|||||||
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。
|
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。
|
||||||
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
|
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
|
||||||
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
|
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
|
||||||
- `main_executable`:业务主程序文件名,默认填 `SimCAE.exe`;如果你的主程序不是这个名字,这里同步修改。
|
- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。
|
||||||
|
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`。
|
||||||
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
|
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
|
||||||
|
|
||||||
## 业务主程序需要配合什么
|
## 业务主程序需要配合什么
|
||||||
@@ -92,15 +145,19 @@ cd client
|
|||||||
-SdkVersion 0.1.0
|
-SdkVersion 0.1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
|
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。
|
||||||
|
|
||||||
生成结果:
|
生成结果:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dist/UpdateClientSDK/
|
dist/UpdateClientSDK/
|
||||||
README.md
|
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||||
|
sdk_manifest.json
|
||||||
bin/
|
bin/
|
||||||
config/
|
config/
|
||||||
|
app_config.json
|
||||||
|
manifest_public_key.pem
|
||||||
scripts/
|
scripts/
|
||||||
samples/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
把 `UpdateClientSDK.zip` 发给接入方即可。
|
把 `UpdateClientSDK.zip` 发给接入方即可。
|
||||||
@@ -123,7 +180,9 @@ cd client
|
|||||||
## 常见错误
|
## 常见错误
|
||||||
|
|
||||||
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
|
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
|
||||||
2. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。
|
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
|
||||||
3. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
|
3. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。
|
||||||
4. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
|
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
|
||||||
5. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
|
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
|
||||||
|
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
|
||||||
|
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
#include <QSet>
|
#include <QSet>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& fromVersion,
|
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
|
||||||
const QString& toVersion, int manifestId)
|
const QString& toVersion, int manifestId)
|
||||||
: m_installDir(QDir::cleanPath(installDir)),
|
: m_installDir(QDir::cleanPath(installDir)),
|
||||||
m_updateDir(QDir(installDir).filePath("update")),
|
m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)),
|
||||||
m_stateFile(QDir(installDir).filePath("update/upgrade_state.json")),
|
m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")),
|
||||||
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
|
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
|
||||||
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
|
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
|
||||||
{
|
{
|
||||||
@@ -223,9 +223,14 @@ QString UpdateTransaction::backupDir() const { return m_backupDir; }
|
|||||||
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
|
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
|
||||||
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
|
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
|
||||||
|
|
||||||
bool UpdateTransaction::recoverInterrupted(const QString& installDir, QString* restoredVersion, QString* errorMessage)
|
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
|
||||||
|
QString* restoredVersion, QString* errorMessage)
|
||||||
{
|
{
|
||||||
const QString stateFile = QDir(installDir).filePath("update/upgrade_state.json");
|
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
|
||||||
|
.filePath("upgrade_state.json");
|
||||||
|
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
|
||||||
|
if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile))
|
||||||
|
stateFile = legacyStateFile;
|
||||||
QFile file(stateFile);
|
QFile file(stateFile);
|
||||||
if (!file.exists()) return true;
|
if (!file.exists()) return true;
|
||||||
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
|
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
class UpdateTransaction
|
class UpdateTransaction
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
UpdateTransaction(const QString& installDir, const QString& fromVersion,
|
UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
|
||||||
const QString& toVersion, int manifestId);
|
const QString& toVersion, int manifestId);
|
||||||
|
|
||||||
bool initialize();
|
bool initialize();
|
||||||
@@ -29,7 +29,7 @@ public:
|
|||||||
QStringList obsoletePaths() const;
|
QStringList obsoletePaths() const;
|
||||||
QString healthFile() const;
|
QString healthFile() const;
|
||||||
|
|
||||||
static bool recoverInterrupted(const QString& installDir,
|
static bool recoverInterrupted(const QString& installDir, const QString& updateDir,
|
||||||
QString* restoredVersion = nullptr,
|
QString* restoredVersion = nullptr,
|
||||||
QString* errorMessage = nullptr);
|
QString* errorMessage = nullptr);
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
|||||||
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
||||||
}
|
}
|
||||||
|
|
||||||
const QSet<QString> protectedPaths{
|
QSet<QString> protectedPaths{
|
||||||
QStringLiteral("bootstrap.exe"),
|
QStringLiteral("bootstrap.exe"),
|
||||||
QStringLiteral("launcher.exe"),
|
QStringLiteral("launcher.exe"),
|
||||||
QStringLiteral("updater.exe"),
|
QStringLiteral("updater.exe"),
|
||||||
@@ -273,6 +273,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
|||||||
QStringLiteral("config/client_identity.dat"),
|
QStringLiteral("config/client_identity.dat"),
|
||||||
QStringLiteral("config/version_policy.dat")
|
QStringLiteral("config/version_policy.dat")
|
||||||
};
|
};
|
||||||
|
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||||
|
if (!runtimePrefix.isEmpty()) {
|
||||||
|
const QStringList runtimeProtected{
|
||||||
|
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"),
|
||||||
|
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
|
||||||
|
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
|
||||||
|
QStringLiteral("config/version_policy.dat")
|
||||||
|
};
|
||||||
|
for (const QString& path : runtimeProtected)
|
||||||
|
protectedPaths.insert(runtimePrefix + "/" + path);
|
||||||
|
}
|
||||||
QStringList obsolete;
|
QStringList obsolete;
|
||||||
QSet<QString> seen;
|
QSet<QString> seen;
|
||||||
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
|
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
|
||||||
@@ -550,7 +561,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
||||||
{
|
{
|
||||||
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||||
static const QSet<QString> protectedPaths{
|
QSet<QString> protectedPaths{
|
||||||
QStringLiteral("bootstrap.exe"),
|
QStringLiteral("bootstrap.exe"),
|
||||||
QStringLiteral("client.ini"),
|
QStringLiteral("client.ini"),
|
||||||
QStringLiteral("config/app_config.json"),
|
QStringLiteral("config/app_config.json"),
|
||||||
@@ -558,6 +569,16 @@ bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
|||||||
QStringLiteral("config/client_identity.dat"),
|
QStringLiteral("config/client_identity.dat"),
|
||||||
QStringLiteral("config/version_policy.dat")
|
QStringLiteral("config/version_policy.dat")
|
||||||
};
|
};
|
||||||
|
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||||
|
if (!runtimePrefix.isEmpty()) {
|
||||||
|
const QStringList runtimeProtected{
|
||||||
|
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
|
||||||
|
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
|
||||||
|
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
|
||||||
|
};
|
||||||
|
for (const QString& protectedPath : runtimeProtected)
|
||||||
|
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
||||||
|
}
|
||||||
return protectedPaths.contains(normalized);
|
return protectedPaths.contains(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,7 +597,7 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
|||||||
const QStringList& obsoletePaths) const
|
const QStringList& obsoletePaths) const
|
||||||
{
|
{
|
||||||
qint64 required = 0;
|
qint64 required = 0;
|
||||||
const QString cacheDir = QDir(targetDir).filePath("update/download_cache");
|
const QString cacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
|
||||||
for (const auto& item : m_fileItems) {
|
for (const auto& item : m_fileItems) {
|
||||||
const QString relativePath = QDir::fromNativeSeparators(item.path);
|
const QString relativePath = QDir::fromNativeSeparators(item.path);
|
||||||
if (isRuntimeProtectedPath(relativePath)) continue;
|
if (isRuntimeProtectedPath(relativePath)) continue;
|
||||||
@@ -609,7 +630,7 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
|||||||
QDir stagingDir(tempDir);
|
QDir stagingDir(tempDir);
|
||||||
if (!FileHelper::createDir(tempDir))
|
if (!FileHelper::createDir(tempDir))
|
||||||
return false;
|
return false;
|
||||||
const QString resumeCacheDir = QDir(targetDir).filePath("update/download_cache");
|
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
|
||||||
if (!FileHelper::createDir(resumeCacheDir))
|
if (!FileHelper::createDir(resumeCacheDir))
|
||||||
return false;
|
return false;
|
||||||
QSet<QString> activePartialNames;
|
QSet<QString> activePartialNames;
|
||||||
|
|||||||
+18
-12
@@ -59,16 +59,19 @@ int main(int argc, char* argv[])
|
|||||||
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
||||||
}
|
}
|
||||||
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
||||||
const QString targetDir = QApplication::applicationDirPath();
|
const QString runtimeDir = QApplication::applicationDirPath();
|
||||||
|
|
||||||
ConfigHelper& config = ConfigHelper::instance();
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
|
const QString targetDir = config.installRoot();
|
||||||
|
const QString updateDir = config.updateRoot();
|
||||||
|
QDir().mkpath(updateDir);
|
||||||
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
||||||
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
|
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
QString fromVersion = config.getValue("App", "current_version");
|
QString fromVersion = config.getValue("App", "current_version");
|
||||||
if (!offlinePackagePath.isEmpty()) {
|
if (!offlinePackagePath.isEmpty()) {
|
||||||
PolicyHelper offlinePolicy(targetDir);
|
PolicyHelper offlinePolicy(runtimeDir);
|
||||||
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
||||||
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
||||||
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
|
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
|
||||||
@@ -87,7 +90,7 @@ int main(int argc, char* argv[])
|
|||||||
if (!resumingFromBootstrap) {
|
if (!resumingFromBootstrap) {
|
||||||
QString restoredVersion;
|
QString restoredVersion;
|
||||||
QString recoveryError;
|
QString recoveryError;
|
||||||
if (!UpdateTransaction::recoverInterrupted(targetDir, &restoredVersion, &recoveryError)) {
|
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
||||||
QMessageBox::critical(nullptr, "更新恢复失败",
|
QMessageBox::critical(nullptr, "更新恢复失败",
|
||||||
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
||||||
return -1;
|
return -1;
|
||||||
@@ -111,7 +114,7 @@ int main(int argc, char* argv[])
|
|||||||
progress.show();
|
progress.show();
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
|
|
||||||
UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId);
|
UpdateTransaction transaction(targetDir, updateDir, 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);
|
||||||
progress.setLabelText(message);
|
progress.setLabelText(message);
|
||||||
@@ -160,9 +163,9 @@ int main(int argc, char* argv[])
|
|||||||
bool timeoutOk = false;
|
bool timeoutOk = false;
|
||||||
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
|
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
|
||||||
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
|
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
|
||||||
const QString mainAppPath = QDir(targetDir).filePath(mainExecutable);
|
const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable);
|
||||||
const QString updaterPath = QDir(targetDir).filePath(updaterExecutable);
|
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
|
||||||
const QString bootstrapPath = QDir(targetDir).filePath(bootstrapExecutable);
|
const QString bootstrapPath = QDir(runtimeDir).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()) {
|
||||||
QString ticketPath;
|
QString ticketPath;
|
||||||
@@ -180,7 +183,7 @@ int main(int argc, char* argv[])
|
|||||||
return started;
|
return started;
|
||||||
};
|
};
|
||||||
const auto bootstrapPlanFile = [&]() {
|
const auto bootstrapPlanFile = [&]() {
|
||||||
return QDir(targetDir).filePath("update/bootstrap_plan_" + transaction.transactionId() + ".txt");
|
return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt");
|
||||||
};
|
};
|
||||||
const auto launchBootstrap = [&](const QString& mode) {
|
const auto launchBootstrap = [&](const QString& mode) {
|
||||||
const QStringList args{
|
const QStringList args{
|
||||||
@@ -245,7 +248,7 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
|
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
|
||||||
const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache");
|
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
|
||||||
if (resumingFromBootstrap) {
|
if (resumingFromBootstrap) {
|
||||||
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
||||||
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
|
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
|
||||||
@@ -282,7 +285,7 @@ int main(int argc, char* argv[])
|
|||||||
if (logic.getFileList().isEmpty())
|
if (logic.getFileList().isEmpty())
|
||||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||||
|
|
||||||
QStorageInfo storage(targetDir);
|
QStorageInfo storage(updateDir);
|
||||||
storage.refresh();
|
storage.refresh();
|
||||||
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
||||||
const qint64 availableBytes = storage.bytesAvailable();
|
const qint64 availableBytes = storage.bytesAvailable();
|
||||||
@@ -316,9 +319,12 @@ int main(int argc, char* argv[])
|
|||||||
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
|
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
|
||||||
while (stagingFiles.hasNext())
|
while (stagingFiles.hasNext())
|
||||||
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
|
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
|
||||||
|
const QString runtimePrefix = config.runtimeRelativePath();
|
||||||
|
const QString bootstrapManifestPath = QDir::fromNativeSeparators(
|
||||||
|
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
|
||||||
for (const QString& path : changedPaths) {
|
for (const QString& path : changedPaths) {
|
||||||
if (path.compare(bootstrapExecutable, Qt::CaseInsensitive) == 0)
|
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
||||||
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapExecutable), "bootstrap_self_update_blocked");
|
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked");
|
||||||
}
|
}
|
||||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||||
|
|||||||
-1079
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
|||||||
[Server]
|
|
||||||
api_base_url=http://192.168.229.128:8000
|
|
||||||
client_token=AuthKey202606Demo
|
|
||||||
|
|
||||||
[App]
|
|
||||||
app_id=testadmin
|
|
||||||
current_version=1.0.0
|
|
||||||
channel=stable
|
|
||||||
launch_token=UpdateSecret2026Demo
|
|
||||||
|
|
||||||
[Update]
|
|
||||||
request_timeout_ms=5000
|
|
||||||
temp_folder=update_temp
|
|
||||||
device_id=test_device_001
|
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"request_timeout_ms": "5000",
|
"request_timeout_ms": "5000",
|
||||||
"temp_folder": "update_temp",
|
"temp_folder": "update_temp",
|
||||||
"device_id": "",
|
"device_id": "",
|
||||||
|
"install_root": "..",
|
||||||
"main_executable": "SimCAE.exe",
|
"main_executable": "SimCAE.exe",
|
||||||
"launcher_executable": "Launcher.exe",
|
"launcher_executable": "Launcher.exe",
|
||||||
"updater_executable": "Updater.exe",
|
"updater_executable": "Updater.exe",
|
||||||
|
|||||||
+35
-2
@@ -4,7 +4,9 @@ param(
|
|||||||
|
|
||||||
[string]$ReleaseDir = (Get-Location).Path,
|
[string]$ReleaseDir = (Get-Location).Path,
|
||||||
|
|
||||||
[switch]$OverwriteConfig
|
[switch]$OverwriteConfig,
|
||||||
|
|
||||||
|
[switch]$IncludeQtRuntime
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -23,7 +25,38 @@ foreach ($path in @($binDir, $appConfig, $publicKey)) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Copy-Item (Join-Path $binDir "*") $release -Recurse -Force
|
function Test-IsQtRuntimeItem {
|
||||||
|
param([System.IO.FileSystemInfo]$Item)
|
||||||
|
|
||||||
|
if ($IncludeQtRuntime) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Item.PSIsContainer) {
|
||||||
|
return $Item.Name -in @(
|
||||||
|
"bearer",
|
||||||
|
"iconengines",
|
||||||
|
"imageformats",
|
||||||
|
"platforms",
|
||||||
|
"styles",
|
||||||
|
"translations"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
$Item.Name -match '^Qt5.*\.dll$' -or
|
||||||
|
$Item.Name -in @(
|
||||||
|
"libEGL.dll",
|
||||||
|
"libGLESv2.dll",
|
||||||
|
"opengl32sw.dll",
|
||||||
|
"d3dcompiler_47.dll"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Get-ChildItem $binDir -Force | Where-Object {
|
||||||
|
-not (Test-IsQtRuntimeItem $_)
|
||||||
|
} | Copy-Item -Destination $release -Recurse -Force
|
||||||
|
|
||||||
$targetConfigDir = Join-Path $release "config"
|
$targetConfigDir = Join-Path $release "config"
|
||||||
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
||||||
|
|||||||
+64
-13
@@ -12,6 +12,31 @@ $source = (Resolve-Path $SourceDir).Path
|
|||||||
$config = (Resolve-Path $ConfigFile).Path
|
$config = (Resolve-Path $ConfigFile).Path
|
||||||
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
|
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
|
||||||
|
function Normalize-RelativePath([string]$PathValue) {
|
||||||
|
return ($PathValue -replace '\\', '/').Trim('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) {
|
||||||
|
$full = (Resolve-Path $FullPath).Path
|
||||||
|
if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/'))
|
||||||
|
}
|
||||||
|
return Normalize-RelativePath $Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json"
|
||||||
|
$configRelativeParent = Split-Path $configRelativePath -Parent
|
||||||
|
$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent)
|
||||||
|
if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" }
|
||||||
|
|
||||||
|
function Join-RelativePath([string]$Base, [string]$Child) {
|
||||||
|
$baseNorm = Normalize-RelativePath $Base
|
||||||
|
$childNorm = Normalize-RelativePath $Child
|
||||||
|
if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm }
|
||||||
|
if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm }
|
||||||
|
return "$baseNorm/$childNorm"
|
||||||
|
}
|
||||||
|
|
||||||
$requiredFields = @(
|
$requiredFields = @(
|
||||||
"app_id", "channel", "api_base_url", "current_version",
|
"app_id", "channel", "api_base_url", "current_version",
|
||||||
"client_token", "launch_token", "license_key",
|
"client_token", "launch_token", "license_key",
|
||||||
@@ -23,14 +48,20 @@ foreach ($field in $requiredFields) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$mainExecutable = Normalize-RelativePath $settings.main_executable
|
||||||
|
$launcherExecutable = Normalize-RelativePath $settings.launcher_executable
|
||||||
|
$updaterExecutable = Normalize-RelativePath $settings.updater_executable
|
||||||
|
$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable
|
||||||
|
|
||||||
$requiredFiles = @(
|
$requiredFiles = @(
|
||||||
$settings.main_executable,
|
(Join-RelativePath $runtimeDirRelative $mainExecutable),
|
||||||
$settings.launcher_executable,
|
(Join-RelativePath $runtimeDirRelative $launcherExecutable),
|
||||||
$settings.updater_executable,
|
(Join-RelativePath $runtimeDirRelative $updaterExecutable),
|
||||||
$settings.bootstrap_executable
|
(Join-RelativePath $runtimeDirRelative $bootstrapExecutable)
|
||||||
)
|
)
|
||||||
foreach ($name in $requiredFiles) {
|
foreach ($name in $requiredFiles) {
|
||||||
if (-not (Test-Path (Join-Path $source $name))) {
|
$nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar
|
||||||
|
if (-not (Test-Path (Join-Path $source $nativeName))) {
|
||||||
throw "Source directory is missing required file: $name"
|
throw "Source directory is missing required file: $name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,15 +74,25 @@ if ($debugArtifacts) {
|
|||||||
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
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 {
|
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
||||||
$_.DirectoryName -ne $source
|
$mainLeafName = Split-Path $mainExecutable -Leaf
|
||||||
|
$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object {
|
||||||
|
$relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/'))
|
||||||
|
$relative -ne $expectedMainPath
|
||||||
} | Select-Object -First 1
|
} | Select-Object -First 1
|
||||||
if ($nestedMain) {
|
if ($duplicateMain) {
|
||||||
throw "Source directory contains a nested duplicate main executable. Use a clean Release root directory: $($nestedMain.FullName)"
|
throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
$manifestName = "manifest_$($settings.current_version).json"
|
$manifestName = "manifest_$($settings.current_version).json"
|
||||||
$sourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
|
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
|
||||||
|
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||||
|
if (-not (Test-Path $sourceManifest)) {
|
||||||
|
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
|
||||||
|
if (Test-Path $legacySourceManifest) {
|
||||||
|
$sourceManifest = $legacySourceManifest
|
||||||
|
}
|
||||||
|
}
|
||||||
if (-not (Test-Path $sourceManifest)) {
|
if (-not (Test-Path $sourceManifest)) {
|
||||||
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
|
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
|
||||||
}
|
}
|
||||||
@@ -65,16 +106,26 @@ Get-ChildItem $source -Force | Where-Object {
|
|||||||
$_.Name -notin @("update", "update_temp")
|
$_.Name -notin @("update", "update_temp")
|
||||||
} | Copy-Item -Destination $OutputDir -Recurse -Force
|
} | Copy-Item -Destination $OutputDir -Recurse -Force
|
||||||
|
|
||||||
$outputConfigDir = Join-Path $OutputDir "config"
|
$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||||
|
if (Test-Path $outputRuntimeUpdateDir) {
|
||||||
|
Remove-Item $outputRuntimeUpdateDir -Recurse -Force
|
||||||
|
}
|
||||||
|
$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||||
|
if (Test-Path $outputRuntimeTempDir) {
|
||||||
|
Remove-Item $outputRuntimeTempDir -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||||
|
$outputConfigDir = Split-Path $outputConfigPath -Parent
|
||||||
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
|
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
|
||||||
Copy-Item $config (Join-Path $outputConfigDir "app_config.json") -Force
|
Copy-Item $config $outputConfigPath -Force
|
||||||
|
|
||||||
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
|
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
|
||||||
$runtimeFile = Join-Path $outputConfigDir $_
|
$runtimeFile = Join-Path $outputConfigDir $_
|
||||||
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force }
|
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force }
|
||||||
}
|
}
|
||||||
|
|
||||||
$manifestDir = Join-Path $OutputDir "update/manifest_cache"
|
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||||
New-Item $manifestDir -ItemType Directory -Force | Out-Null
|
New-Item $manifestDir -ItemType Directory -Force | Out-Null
|
||||||
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -4,7 +4,8 @@ param(
|
|||||||
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
|
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
|
||||||
[string]$SdkVersion = "0.1.0",
|
[string]$SdkVersion = "0.1.0",
|
||||||
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
|
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
|
||||||
[switch]$IncludeDemoMainApp
|
[switch]$IncludeDemoMainApp,
|
||||||
|
[switch]$IncludeQtRuntime
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -46,11 +47,40 @@ $configDir = Join-Path $OutputDir "config"
|
|||||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||||
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
|
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
$excludedTopLevel = @("config", "update", "update_temp")
|
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
|
||||||
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
|
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
|
||||||
|
|
||||||
|
if (-not $IncludeQtRuntime) {
|
||||||
|
$excludedTopLevel += @(
|
||||||
|
"bearer",
|
||||||
|
"iconengines",
|
||||||
|
"imageformats",
|
||||||
|
"platforms",
|
||||||
|
"styles",
|
||||||
|
"translations"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-IsQtRuntimeFile {
|
||||||
|
param([System.IO.FileSystemInfo]$Item)
|
||||||
|
|
||||||
|
if ($IncludeQtRuntime -or $Item.PSIsContainer) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
$Item.Name -match '^Qt5.*\.dll$' -or
|
||||||
|
$Item.Name -in @(
|
||||||
|
"libEGL.dll",
|
||||||
|
"libGLESv2.dll",
|
||||||
|
"opengl32sw.dll",
|
||||||
|
"d3dcompiler_47.dll"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Get-ChildItem $source -Force | Where-Object {
|
Get-ChildItem $source -Force | Where-Object {
|
||||||
$_.Name -notin $excludedTopLevel
|
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
|
||||||
} | Copy-Item -Destination $binDir -Recurse -Force
|
} | Copy-Item -Destination $binDir -Recurse -Force
|
||||||
|
|
||||||
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
||||||
|
|||||||
@@ -18,12 +18,21 @@ CORS_ALLOW_ORIGINS=*
|
|||||||
TARGET_PLATFORM=windows
|
TARGET_PLATFORM=windows
|
||||||
TARGET_ARCH=x64
|
TARGET_ARCH=x64
|
||||||
|
|
||||||
# 业务主程序文件名。必须和你发布目录根级的主 exe 名称一致。
|
# 业务主程序相对发布根目录的路径。SimCAE 默认在 bin/SimCAE.exe。
|
||||||
RELEASE_MAIN_EXECUTABLE=SimCAE.exe
|
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||||
|
|
||||||
# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。
|
# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。
|
||||||
SIGNING_KEY_ID=manifest-key-v1
|
SIGNING_KEY_ID=manifest-key-v1
|
||||||
|
|
||||||
|
# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。
|
||||||
|
# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。
|
||||||
|
PUBLISH_MAX_REQUEST_MB=4096
|
||||||
|
PUBLISH_MAX_FILES=20000
|
||||||
|
# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。
|
||||||
|
PUBLISH_MAX_FIELDS=20100
|
||||||
|
# 上传时额外保留的磁盘空间,单位 MB。通常不用改。
|
||||||
|
UPLOAD_SPACE_RESERVE_MB=256
|
||||||
|
|
||||||
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
|
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
|
||||||
CLIENT_API_TOKEN=SimCAEClientToken2026
|
CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -6,11 +6,11 @@ SERVER_PORT=8000
|
|||||||
APP_UID=1000
|
APP_UID=1000
|
||||||
APP_GID=1000
|
APP_GID=1000
|
||||||
SERVICE_TITLE=Software Update Service
|
SERVICE_TITLE=Software Update Service
|
||||||
ADMIN_HTML_PATH=../client/admin.html
|
ADMIN_HTML_PATH=admin.html
|
||||||
CORS_ALLOW_ORIGINS=*
|
CORS_ALLOW_ORIGINS=*
|
||||||
TARGET_PLATFORM=windows
|
TARGET_PLATFORM=windows
|
||||||
TARGET_ARCH=x64
|
TARGET_ARCH=x64
|
||||||
RELEASE_MAIN_EXECUTABLE=MainApp.exe
|
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||||
SIGNING_KEY_ID=manifest-key-v1
|
SIGNING_KEY_ID=manifest-key-v1
|
||||||
|
|
||||||
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
|
# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。
|
||||||
@@ -27,6 +27,13 @@ MANIFEST_PRIVATE_KEY_PATH=keys/manifest_private_key.pem
|
|||||||
# 上传暂存和本地兜底存储目录。通常不用改。
|
# 上传暂存和本地兜底存储目录。通常不用改。
|
||||||
UPLOAD_SPOOL_DIR=upload_spool
|
UPLOAD_SPOOL_DIR=upload_spool
|
||||||
UPLOAD_SPACE_RESERVE_MB=256
|
UPLOAD_SPACE_RESERVE_MB=256
|
||||||
|
|
||||||
|
# 发布新版本保护。超过上限会直接拒绝,避免大目录上传把服务器磁盘或内存拖死。
|
||||||
|
# 如果你的正式发布包超过 4GB,可以按服务器磁盘空间调大。
|
||||||
|
PUBLISH_MAX_REQUEST_MB=4096
|
||||||
|
PUBLISH_MAX_FILES=20000
|
||||||
|
# 通常不用改;每个上传文件还会带一个 relative_paths 字段,所以要大于 PUBLISH_MAX_FILES。
|
||||||
|
PUBLISH_MAX_FIELDS=20100
|
||||||
LOCAL_UPLOAD_ROOT=local_uploads
|
LOCAL_UPLOAD_ROOT=local_uploads
|
||||||
LOCAL_FILE_URL_BASE=http://127.0.0.1:8000/static
|
LOCAL_FILE_URL_BASE=http://127.0.0.1:8000/static
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Operating systems and editors
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*.log
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Private requirement / handover documents
|
||||||
|
*.doc
|
||||||
|
*.docx
|
||||||
|
*交付说明*.md
|
||||||
|
|
||||||
|
# Python environments and caches
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
**/__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Local configuration and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.docker.example
|
||||||
|
admin_token.sha256
|
||||||
|
keys/*private*.pem
|
||||||
|
keys/*private*.key
|
||||||
|
|
||||||
|
# Runtime databases, uploads, object storage and generated packages
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
local_uploads/
|
||||||
|
upload_spool/
|
||||||
|
crash_storage/
|
||||||
|
minio_data/
|
||||||
|
runtime/
|
||||||
|
dist/
|
||||||
|
minio
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
*.pid
|
||||||
|
|
||||||
|
# Docker/local generated files
|
||||||
|
.dockerignore.local
|
||||||
+4
-1
@@ -6,12 +6,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp
|
RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libarchive-tools \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY server/requirements.txt ./requirements.txt
|
COPY server/requirements.txt ./requirements.txt
|
||||||
RUN pip install --no-cache-dir -r 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 server/main.py server/db.py server/minio_tool.py server/tables.sql ./
|
||||||
COPY --chown=updateapp:updateapp client/admin.html ./admin.html
|
COPY --chown=updateapp:updateapp server/admin.html ./admin.html
|
||||||
|
|
||||||
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \
|
||||||
&& chown -R updateapp:updateapp /app /data \
|
&& chown -R updateapp:updateapp /app /data \
|
||||||
|
|||||||
+2428
File diff suppressed because it is too large
Load Diff
@@ -42,12 +42,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SERVER_HOST: 0.0.0.0
|
SERVER_HOST: 0.0.0.0
|
||||||
SERVER_PORT: 8000
|
SERVER_PORT: 8000
|
||||||
|
ENV_FILE_PATH: /app/.env
|
||||||
DB_FILE: /data/mini.db
|
DB_FILE: /data/mini.db
|
||||||
SQL_FILE: /app/tables.sql
|
SQL_FILE: /app/tables.sql
|
||||||
ADMIN_HTML_PATH: /app/admin.html
|
ADMIN_HTML_PATH: /app/admin.html
|
||||||
LOCAL_UPLOAD_ROOT: /data/uploads
|
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||||
UPLOAD_SPOOL_DIR: /data/upload_spool
|
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||||
MINIO_DATA_DIR: /data
|
MINIO_DATA_DIR: /minio_data
|
||||||
CRASH_STORAGE_ROOT: /data/crash_storage
|
CRASH_STORAGE_ROOT: /data/crash_storage
|
||||||
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
||||||
MINIO_ENDPOINT: minio:9000
|
MINIO_ENDPOINT: minio:9000
|
||||||
@@ -57,7 +58,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./.env:/app/.env
|
||||||
- ./runtime:/data
|
- ./runtime:/data
|
||||||
|
- ./minio_data:/minio_data:ro
|
||||||
- ./keys:/run/secrets/update-keys:ro
|
- ./keys:/run/secrets/update-keys:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
|
|||||||
@@ -44,12 +44,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
SERVER_HOST: 0.0.0.0
|
SERVER_HOST: 0.0.0.0
|
||||||
SERVER_PORT: 8000
|
SERVER_PORT: 8000
|
||||||
|
ENV_FILE_PATH: /app/.env
|
||||||
DB_FILE: /data/mini.db
|
DB_FILE: /data/mini.db
|
||||||
SQL_FILE: /app/tables.sql
|
SQL_FILE: /app/tables.sql
|
||||||
ADMIN_HTML_PATH: /app/admin.html
|
ADMIN_HTML_PATH: /app/admin.html
|
||||||
LOCAL_UPLOAD_ROOT: /data/uploads
|
LOCAL_UPLOAD_ROOT: /data/uploads
|
||||||
UPLOAD_SPOOL_DIR: /data/upload_spool
|
UPLOAD_SPOOL_DIR: /data/upload_spool
|
||||||
MINIO_DATA_DIR: /data
|
MINIO_DATA_DIR: /minio_data
|
||||||
CRASH_STORAGE_ROOT: /data/crash_storage
|
CRASH_STORAGE_ROOT: /data/crash_storage
|
||||||
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem
|
||||||
MINIO_ENDPOINT: minio:9000
|
MINIO_ENDPOINT: minio:9000
|
||||||
@@ -59,7 +60,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${SERVER_PORT:-8000}:8000"
|
- "${SERVER_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./.env:/app/.env
|
||||||
- ./runtime:/data
|
- ./runtime:/data
|
||||||
|
- ./minio_data:/minio_data:ro
|
||||||
- ./keys:/run/secrets/update-keys:ro
|
- ./keys:/run/secrets/update-keys:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
|
|||||||
+749
-180
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,24 @@ def put_file(object_path: str, data: bytes, size: int):
|
|||||||
return {'storage': 'local', 'path': str(local_path)}
|
return {'storage': 'local', 'path': str(local_path)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def put_file_stream(object_path: str, source, size: int):
|
||||||
|
bucket = MINIO_BUCKET
|
||||||
|
try:
|
||||||
|
source.seek(0)
|
||||||
|
mc.put_object(bucket, object_path, source, length=size)
|
||||||
|
return {'storage': 'minio', 'path': object_path}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"minio put_file_stream error: {e}")
|
||||||
|
local_path = LOCAL_UPLOAD_ROOT / object_path
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source.seek(0)
|
||||||
|
with open(local_path, 'wb') as target:
|
||||||
|
shutil.copyfileobj(source, target, length=1024 * 1024)
|
||||||
|
print(f"local fallback stored {local_path}")
|
||||||
|
return {'storage': 'local', 'path': str(local_path)}
|
||||||
|
|
||||||
|
|
||||||
def remove_prefix(prefix: str):
|
def remove_prefix(prefix: str):
|
||||||
bucket = MINIO_BUCKET
|
bucket = MINIO_BUCKET
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -105,11 +105,15 @@ mkdir -p "$IMAGES_DIR" "$KEYS_DIR"
|
|||||||
|
|
||||||
cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
|
cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml"
|
||||||
cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example"
|
cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example"
|
||||||
|
cp "$SCRIPT_DIR/keys/manifest_private_key.pem" "$KEYS_DIR/manifest_private_key.pem"
|
||||||
|
cp "$SCRIPT_DIR/keys/manifest_public_key.pem" "$KEYS_DIR/manifest_public_key.pem"
|
||||||
|
|
||||||
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
|
cat > "$PACKAGE_DIR/README.md" <<'EOF_README'
|
||||||
# SimCAE 服务端 Docker 部署包说明
|
# SimCAE 服务端 Docker 部署包说明
|
||||||
|
|
||||||
这个包已经包含服务端运行需要的 Docker 镜像、配置模板、签名密钥和编排文件。你的服务器无论能不能联网,都可以按下面流程部署。
|
这个包用于把 SimCAE 自动升级服务端部署到你的服务器。包里已经包含后端 API、MinIO 对象存储、初始化工具、配置模板、签名密钥和 Docker 编排文件;你的服务器即使不能联网,也可以按本文完成部署。
|
||||||
|
|
||||||
|
如果你只想先跑起来,按“二、最小部署流程”执行即可。后面的配置说明用于解释每个字段是什么意思、哪些必须改、哪些保持默认也能运行。
|
||||||
|
|
||||||
## 一、这个包里面有什么
|
## 一、这个包里面有什么
|
||||||
|
|
||||||
@@ -122,13 +126,24 @@ SimCAEServerDockerPackage/
|
|||||||
.env.example 环境变量模板
|
.env.example 环境变量模板
|
||||||
load-images.sh 导入 Docker 镜像并创建 .env 的脚本
|
load-images.sh 导入 Docker 镜像并创建 .env 的脚本
|
||||||
images/
|
images/
|
||||||
simcae-server-all-images___VERSION__.tar 三个 Docker 镜像:api、minio、minio-init
|
simcae-server-all-images___VERSION__.tar Docker 镜像包,包含 api、minio、minio-init
|
||||||
keys/
|
keys/
|
||||||
manifest_private_key.pem 服务端 Manifest 签名私钥
|
manifest_private_key.pem 服务端 Manifest 签名私钥
|
||||||
manifest_public_key.pem 与客户端配套的验签公钥
|
manifest_public_key.pem 与客户端配套的验签公钥
|
||||||
```
|
```
|
||||||
|
|
||||||
## 二、你在服务器上怎么运行
|
这些文件的关系可以这样理解:
|
||||||
|
|
||||||
|
```text
|
||||||
|
images/*.tar 程序镜像包,相当于安装材料
|
||||||
|
docker-compose.yml 容器部署图纸,说明启动哪些服务、端口怎么映射、目录怎么挂载
|
||||||
|
.env 你的实际配置,第一次运行 load-images.sh 时由 .env.example 生成
|
||||||
|
keys/ 发布版本时用于签名 Manifest,客户端用对应公钥验签
|
||||||
|
runtime/ 启动后自动生成,保存服务端数据库、上传临时文件、崩溃报告等
|
||||||
|
minio_data/ 启动后自动生成,保存升级包文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 二、最小部署流程
|
||||||
|
|
||||||
先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行:
|
先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行:
|
||||||
|
|
||||||
@@ -151,9 +166,7 @@ keys/
|
|||||||
|
|
||||||
后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。
|
后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。
|
||||||
|
|
||||||
## 三、导入镜像并生成 .env
|
继续在 `SimCAEServerDockerPackage` 目录执行:
|
||||||
|
|
||||||
在 `SimCAEServerDockerPackage` 目录执行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash ./load-images.sh
|
bash ./load-images.sh
|
||||||
@@ -164,71 +177,41 @@ bash ./load-images.sh
|
|||||||
```text
|
```text
|
||||||
1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像
|
1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像
|
||||||
2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env
|
2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env
|
||||||
|
3. 自动创建 runtime/、minio_data/ 等运行目录,并尽量修正目录权限
|
||||||
```
|
```
|
||||||
|
|
||||||
## 四、修改 .env
|
然后修改 `.env`:
|
||||||
|
|
||||||
继续在 `SimCAEServerDockerPackage` 目录执行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nano .env
|
nano .env
|
||||||
```
|
```
|
||||||
|
|
||||||
`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址:
|
最少只需要把 `MINIO_PUBLIC_ENDPOINT` 改成 Windows 客户端能访问到的服务器地址:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||||
```
|
```
|
||||||
|
|
||||||
正式部署时,建议同时修改这些默认值,避免所有环境共用同一套公开示例密码:
|
例如服务器 IP 是 `192.168.229.128`:
|
||||||
|
|
||||||
```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
|
```text
|
||||||
MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000
|
MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000
|
||||||
```
|
```
|
||||||
|
|
||||||
## 五、启动服务
|
启动服务:
|
||||||
|
|
||||||
确认你仍然在 `SimCAEServerDockerPackage` 目录:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pwd
|
|
||||||
```
|
|
||||||
|
|
||||||
然后启动:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。
|
查看状态和后端日志:
|
||||||
|
|
||||||
## 六、查看状态和日志
|
|
||||||
|
|
||||||
这些命令也都在 `SimCAEServerDockerPackage` 目录执行:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose ps
|
docker compose ps
|
||||||
docker compose logs -f api
|
docker compose logs -f api
|
||||||
```
|
```
|
||||||
|
|
||||||
正常情况下可以看到 `api`、`minio` 处于 running/healthy 状态。
|
浏览器访问:
|
||||||
|
|
||||||
## 七、访问地址
|
|
||||||
|
|
||||||
把下面的 `你的服务器IP` 换成真实服务器 IP:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
后台/API: http://你的服务器IP:8000/
|
后台/API: http://你的服务器IP:8000/
|
||||||
@@ -243,9 +226,228 @@ MinIO 控制台: http://你的服务器IP:9001/
|
|||||||
9001 MinIO 控制台,可选
|
9001 MinIO 控制台,可选
|
||||||
```
|
```
|
||||||
|
|
||||||
## 八、重要提醒
|
## 三、.env 配置怎么填
|
||||||
|
|
||||||
|
`.env.example` 里已经给了一组能直接试跑的默认值。下面按重要程度说明。
|
||||||
|
|
||||||
|
### 必须确认或修改
|
||||||
|
|
||||||
|
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `MINIO_PUBLIC_ENDPOINT` | 必填 | 不能直接用于正式环境 | 改成 Windows 客户端能访问到的 MinIO 地址,格式是 `http://服务器IP:9000`。客户端会用这个地址下载升级文件。 |
|
||||||
|
| `SERVER_PORT` | 必填 | 可以 | 后台/API 端口,默认 `8000`。如果服务器 8000 被占用,可以改成其他端口。 |
|
||||||
|
| `RELEASE_MAIN_EXECUTABLE` | 必填 | SimCAE 默认可以 | 发布包里主程序的相对路径。当前 SimCAE 发布根目录下主程序是 `bin/SimCAE.exe`,所以默认是这个。 |
|
||||||
|
| `CLIENT_API_TOKEN` | 必填 | 可以 | 客户端访问服务端 API 的令牌。必须和客户端 `config/app_config.json` 里的 `client_token` 完全一致。 |
|
||||||
|
| `ADMIN_TOKEN` | 必填 | 可以 | 管理后台登录令牌。网页登录时输入这个值。网页里“更改管理员令牌”成功后,会写回当前目录的 `.env`。 |
|
||||||
|
| `MINIO_ACCESS_KEY` | 必填 | 可以 | MinIO 用户名。默认可试跑,正式环境建议改。 |
|
||||||
|
| `MINIO_SECRET_KEY` | 必填 | 可以 | MinIO 密码。默认可试跑,正式环境建议改。 |
|
||||||
|
|
||||||
|
### 通常不用改
|
||||||
|
|
||||||
|
| 字段 | 作用 | 默认值说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SIMCAE_UPDATE_SERVER_IMAGE` | API 镜像名称 | 打包脚本会自动写成当前版本,例如 `simcae-update-server:__VERSION__`。 |
|
||||||
|
| `APP_UID` / `APP_GID` | API 容器写入 `runtime/` 时使用的用户 ID | `load-images.sh` 会尽量自动改成当前服务器用户。遇到权限问题时再检查。 |
|
||||||
|
| `SERVICE_TITLE` | 管理后台标题 | 默认 `SimCAE Update Service`。 |
|
||||||
|
| `CORS_ALLOW_ORIGINS` | 跨域来源 | 内网部署保持 `*` 即可。 |
|
||||||
|
| `TARGET_PLATFORM` / `TARGET_ARCH` | 发布包目标平台 | 当前客户端是 Windows x64,默认 `windows` / `x64`。 |
|
||||||
|
| `SIGNING_KEY_ID` | Manifest 签名密钥编号 | 默认 `manifest-key-v1`。只有更换签名体系时才需要改。 |
|
||||||
|
| `MINIO_API_PORT` | MinIO 文件下载端口 | 默认 `9000`。客户端下载升级文件要能访问这个端口。 |
|
||||||
|
| `MINIO_CONSOLE_PORT` | MinIO 控制台端口 | 默认 `9001`。不需要控制台时可以不开放到外部。 |
|
||||||
|
| `MINIO_BUCKET` | MinIO 存储桶名 | 默认 `updates`。 |
|
||||||
|
| `SIGN_EXPIRE_MIN` | 升级文件下载链接有效期 | 默认 `60` 分钟。 |
|
||||||
|
| `MINIO_CONNECT_TIMEOUT_SEC` / `MINIO_READ_TIMEOUT_SEC` / `MINIO_RETRY_TOTAL` / `MINIO_HEALTH_TIMEOUT_SEC` | MinIO 连接超时与重试 | 默认适合内网部署。 |
|
||||||
|
|
||||||
|
### 发布保护参数
|
||||||
|
|
||||||
|
这些字段用于防止上传超大目录导致服务器磁盘、内存压力过大。默认一般不用改。
|
||||||
|
|
||||||
|
| 字段 | 作用 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PUBLISH_MAX_REQUEST_MB` | 单次发布请求最大大小,单位 MB | `4096` |
|
||||||
|
| `PUBLISH_MAX_FILES` | 单次发布最多文件数 | `20000` |
|
||||||
|
| `PUBLISH_MAX_FIELDS` | 表单字段数上限,通常要大于文件数 | `20100` |
|
||||||
|
| `UPLOAD_SPACE_RESERVE_MB` | 上传时额外保留的磁盘空间,单位 MB | `256` |
|
||||||
|
|
||||||
|
如果正式发布目录超过 4GB,可以在确认服务器磁盘空间足够后调大 `PUBLISH_MAX_REQUEST_MB`。如果页面提示空间不足,优先清理旧版本、扩容磁盘,或把服务端部署到更大的数据盘。
|
||||||
|
|
||||||
|
### 崩溃报告接口参数
|
||||||
|
|
||||||
|
| 字段 | 是否必填 | 默认能否直接用 | 怎么填 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `CRASH_SERVICE_VERSION` | 必填 | 可以 | 崩溃报告接口版本,默认 `1.0.0`。 |
|
||||||
|
| `CRASH_REPORT_TOKEN` | 必填 | 可以 | 客户端上传崩溃报告时使用的令牌。正式环境建议改。 |
|
||||||
|
| `CRASH_SYMBOL_TOKEN` | 必填 | 可以 | 上传符号文件时使用的令牌。正式环境建议改。 |
|
||||||
|
| `CRASH_ADMIN_TOKEN` | 可不填 | 可以 | 崩溃报告管理令牌。不填时使用 `ADMIN_TOKEN`。 |
|
||||||
|
| `CRASH_METADATA_MAX_KB` | 必填 | 可以 | 崩溃报告 metadata 最大大小。 |
|
||||||
|
| `CRASH_MINIDUMP_MAX_MB` | 必填 | 可以 | minidump 最大大小。 |
|
||||||
|
| `CRASH_ATTACHMENTS_MAX_MB` | 必填 | 可以 | 附件最大大小。 |
|
||||||
|
| `CRASH_REQUEST_MAX_MB` | 必填 | 可以 | 崩溃报告完整请求最大大小。 |
|
||||||
|
| `CRASH_SYMBOLS_MAX_MB` | 必填 | 可以 | 符号文件最大大小。 |
|
||||||
|
|
||||||
|
## 四、客户端配置要同步哪些值
|
||||||
|
|
||||||
|
客户端 `config/app_config.json` 至少要和服务端保持这两个值一致:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_base_url": "http://你的服务器IP:8000",
|
||||||
|
"client_token": "和服务端 CLIENT_API_TOKEN 一样"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
如果服务端 `.env` 里保持默认:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLIENT_API_TOKEN=SimCAEClientToken2026
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端就填:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"client_token": "SimCAEClientToken2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
`api_base_url` 是后端 API 地址,走 `SERVER_PORT`,不是 MinIO 地址。
|
||||||
|
|
||||||
|
## 五、发布新版本
|
||||||
|
|
||||||
|
进入后台后,“发布新版本”支持两种方式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 选择软件发布根目录,例如 SimCAE 目录,目录内应包含 bin/SimCAE.exe
|
||||||
|
2. 上传压缩发布包,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar
|
||||||
|
```
|
||||||
|
|
||||||
|
压缩发布包上传到服务器后,服务端会先解压,再校验是否包含 `.env` 中 `RELEASE_MAIN_EXECUTABLE` 指定的主程序路径。当前默认是:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
`.rar` 需要服务器镜像内有 rar 解压工具。当前 Docker 镜像已内置 `bsdtar`;如果某些 rar 变体仍然解压失败,请改用 zip/tar.gz/tar.bz2,或在服务器镜像中补充 `unrar`/`7z`。
|
||||||
|
|
||||||
|
## 六、常用命令
|
||||||
|
|
||||||
|
确认你仍然在 `SimCAEServerDockerPackage` 目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pwd
|
||||||
|
```
|
||||||
|
|
||||||
|
启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
查看状态:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
查看后端日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
查看所有容器日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
重启后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart api
|
||||||
|
```
|
||||||
|
|
||||||
|
停止服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。
|
||||||
|
|
||||||
|
## 七、数据、备份和迁移
|
||||||
|
|
||||||
|
运行后会生成这些目录或文件:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env 当前服务器实际配置
|
||||||
|
runtime/ 服务端数据库、上传临时目录、崩溃报告等
|
||||||
|
minio_data/ MinIO 对象数据,也就是上传后的升级文件
|
||||||
|
keys/ Manifest 签名密钥
|
||||||
|
```
|
||||||
|
|
||||||
|
备份或迁移正式环境时,重点备份:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.env
|
||||||
|
runtime/
|
||||||
|
minio_data/
|
||||||
|
keys/
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
不要只备份容器。容器可以由镜像重新创建,真正重要的数据在上面这些挂载目录里。
|
||||||
|
|
||||||
|
## 八、常见问题
|
||||||
|
|
||||||
|
### 启动后看不到后端日志
|
||||||
|
|
||||||
|
`docker compose up -d` 是后台启动,不会持续打印日志。用下面命令看后端日志:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f api
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 容器反复重启,提示 Permission denied: '/data/uploads'
|
||||||
|
|
||||||
|
这是 `runtime/` 目录权限不对。进入 `SimCAEServerDockerPackage` 目录后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
mkdir -p runtime/uploads runtime/upload_spool runtime/crash_storage
|
||||||
|
sudo chown -R $(id -u):$(id -g) runtime
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网页能打开,但客户端提示令牌校验失败
|
||||||
|
|
||||||
|
检查客户端 `config/app_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"client_token": "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
必须和服务端 `.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CLIENT_API_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
完全一致。
|
||||||
|
|
||||||
|
### 客户端能连 API,但下载升级文件失败
|
||||||
|
|
||||||
|
检查 `.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000
|
||||||
|
```
|
||||||
|
|
||||||
|
这个地址必须从 Windows 客户端能访问。还要确认服务器防火墙放行了 `9000` 端口。
|
||||||
|
|
||||||
|
## 九、安全提醒
|
||||||
|
|
||||||
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
|
`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。
|
||||||
|
|
||||||
|
默认 token 和默认 MinIO 密码可以直接试跑。正式部署建议改掉,避免多个环境共用同一套公开示例值。
|
||||||
EOF_README
|
EOF_README
|
||||||
|
|
||||||
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
|
python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME'
|
||||||
@@ -264,10 +466,27 @@ set -euo pipefail
|
|||||||
docker load -i ./images/simcae-server-all-images_$VERSION.tar
|
docker load -i ./images/simcae-server-all-images_$VERSION.tar
|
||||||
if [[ ! -f .env ]]; then
|
if [[ ! -f .env ]]; then
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
sed -i "s/^APP_UID=.*/APP_UID=\$(id -u)/" .env
|
||||||
|
sed -i "s/^APP_GID=.*/APP_GID=\$(id -g)/" .env
|
||||||
echo "Created .env from .env.example. Edit .env before starting services."
|
echo "Created .env from .env.example. Edit .env before starting services."
|
||||||
else
|
else
|
||||||
echo ".env already exists. Keeping existing file."
|
echo ".env already exists. Keeping existing file."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
APP_UID_VALUE="\$(grep -E '^APP_UID=' .env | tail -n 1 | cut -d= -f2-)"
|
||||||
|
APP_GID_VALUE="\$(grep -E '^APP_GID=' .env | tail -n 1 | cut -d= -f2-)"
|
||||||
|
APP_UID_VALUE="\${APP_UID_VALUE:-\$(id -u)}"
|
||||||
|
APP_GID_VALUE="\${APP_GID_VALUE:-\$(id -g)}"
|
||||||
|
|
||||||
|
mkdir -p ./runtime/uploads ./runtime/upload_spool ./runtime/crash_storage ./minio_data
|
||||||
|
|
||||||
|
if [[ -w ./runtime ]]; then
|
||||||
|
chown -R "\$APP_UID_VALUE:\$APP_GID_VALUE" ./runtime 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo "Warning: ./runtime is not writable by the current user."
|
||||||
|
echo "Run: sudo chown -R \$APP_UID_VALUE:\$APP_GID_VALUE ./runtime"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Next: edit .env, then run: docker compose up -d"
|
echo "Next: edit .env, then run: docker compose up -d"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$PACKAGE_DIR/load-images.sh"
|
chmod +x "$PACKAGE_DIR/load-images.sh"
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ MainApp.exe 当前仓库内的示例业务程序
|
|||||||
- 读取 `config/app_config.json` 配置。
|
- 读取 `config/app_config.json` 配置。
|
||||||
- 首次设备登记和本地设备身份校验。
|
- 首次设备登记和本地设备身份校验。
|
||||||
- License 校验。
|
- License 校验。
|
||||||
|
- `license_key` 为空时,`Launcher.exe` 会弹窗让用户粘贴后台创建的 License,并写入 `config/app_config.json`。当前实现会先检查配置项;即使本地已有 `config/client_identity.dat`,只要清空 `license_key` 仍会提示用户补填 License。
|
||||||
|
- License 过期、错误、禁用、设备数达到上限或设备身份凭证与授权不匹配时,`Launcher.exe` 会引导用户重新输入 License,而不是要求用户手动查找配置文件。
|
||||||
- 版本策略校验。
|
- 版本策略校验。
|
||||||
- 防止策略序号回退。
|
- 防止策略序号回退。
|
||||||
- 检测系统时间回拨。
|
- 检测系统时间回拨。
|
||||||
@@ -58,7 +60,7 @@ MainApp.exe 当前仓库内的示例业务程序
|
|||||||
- 升级后健康检查 `--health-file`,业务程序启动成功后写入 `ok`。
|
- 升级后健康检查 `--health-file`,业务程序启动成功后写入 `ok`。
|
||||||
- 升级失败回滚。
|
- 升级失败回滚。
|
||||||
- 升级日志和下载日志上报服务端。
|
- 升级日志和下载日志上报服务端。
|
||||||
- `admin.html` 管理后台布局已优化。
|
- `server/admin.html` 管理后台布局已优化,升级日志和下载日志默认折叠。
|
||||||
|
|
||||||
## 三、客户端 SDK 交付状态
|
## 三、客户端 SDK 交付状态
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ server/main.py
|
|||||||
server/db.py
|
server/db.py
|
||||||
server/tables.sql
|
server/tables.sql
|
||||||
server/minio_tool.py
|
server/minio_tool.py
|
||||||
server/admin.html 已不再保留,当前使用 client/admin.html
|
server/admin.html 服务端管理后台页面,Docker 构建时复制进镜像
|
||||||
```
|
```
|
||||||
|
|
||||||
已经实现的能力:
|
已经实现的能力:
|
||||||
@@ -114,6 +116,8 @@ server/admin.html 已不再保留,当前使用 client/admin.html
|
|||||||
- License 管理。
|
- License 管理。
|
||||||
- 设备首次登记。
|
- 设备首次登记。
|
||||||
- 版本发布。
|
- 版本发布。
|
||||||
|
- 版本发布支持两种方式:选择软件发布根目录,或上传压缩好的发布包。管理页面已经拆成“软件根目录/压缩发布包”两个发布方式,避免用户误以为只能选择文件夹。
|
||||||
|
- 压缩发布包支持 `zip`、`tar.gz`、`tgz`、`tar.bz2`、`tbz2`、`rar`;服务端会先解压,再按 `RELEASE_MAIN_EXECUTABLE` 校验主程序路径。
|
||||||
- Manifest 生成和私钥签名。
|
- Manifest 生成和私钥签名。
|
||||||
- MinIO 对象存储上传版本文件。
|
- MinIO 对象存储上传版本文件。
|
||||||
- 客户端检查更新接口。
|
- 客户端检查更新接口。
|
||||||
@@ -161,10 +165,8 @@ server/Dockerfile
|
|||||||
server/docker-compose.yml
|
server/docker-compose.yml
|
||||||
server/docker-compose.image.yml
|
server/docker-compose.image.yml
|
||||||
server/package-offline-server.sh
|
server/package-offline-server.sh
|
||||||
server/package-offline-server.ps1
|
|
||||||
server/.env.example
|
server/.env.example
|
||||||
server/.env.docker.example
|
server/.env.docker.example
|
||||||
server/服务端Docker镜像交付说明.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Ubuntu 主流程使用:
|
Ubuntu 主流程使用:
|
||||||
@@ -195,6 +197,8 @@ server/dist/SimCAEServerDockerPackage.tar.gz
|
|||||||
- `keys/manifest_private_key.pem`。
|
- `keys/manifest_private_key.pem`。
|
||||||
- `keys/manifest_public_key.pem`。
|
- `keys/manifest_public_key.pem`。
|
||||||
|
|
||||||
|
当前 Docker 镜像内已安装 `libarchive-tools`,用于后台发布 `.rar` 压缩包时调用 `bsdtar` 解压。`zip` 和 `tar.*` 由 Python 标准库直接支持。
|
||||||
|
|
||||||
注意:Docker 镜像包只包含软件本体和部署配置,不包含已经运行出来的数据。当前服务器上的数据库、上传过的版本文件、崩溃报告等数据在:
|
注意:Docker 镜像包只包含软件本体和部署配置,不包含已经运行出来的数据。当前服务器上的数据库、上传过的版本文件、崩溃报告等数据在:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -246,9 +250,11 @@ manifest_public_key.pem
|
|||||||
- Dockerfile 和 docker-compose 文件。
|
- Dockerfile 和 docker-compose 文件。
|
||||||
- `.env.example` 和 `.env.docker.example`。
|
- `.env.example` 和 `.env.docker.example`。
|
||||||
- Word 接入说明文档。
|
- Word 接入说明文档。
|
||||||
- 服务端 Docker 交付说明文档。
|
- 服务端 Docker 离线包 README 生成逻辑。
|
||||||
- `.gitignore`。
|
- `.gitignore`。
|
||||||
|
|
||||||
|
默认值说明:服务端和客户端示例配置里已经放了可直接试跑的默认 token、MinIO 用户名和密码,目的是让接收方不用一上来就被配置卡住。它们可以直接用于内网联调;如果进入正式生产或公网环境,再按安全要求替换。
|
||||||
|
|
||||||
不建议提交:
|
不建议提交:
|
||||||
|
|
||||||
- `client/App/`
|
- `client/App/`
|
||||||
@@ -262,14 +268,41 @@ manifest_public_key.pem
|
|||||||
- `server/keys/manifest_private_key.pem`
|
- `server/keys/manifest_private_key.pem`
|
||||||
- 任何真实 token、License、私钥和运行日志。
|
- 任何真实 token、License、私钥和运行日志。
|
||||||
|
|
||||||
## 九、后续待办
|
## 九、SimCAE 安装根目录模式
|
||||||
|
|
||||||
|
SimCAE 的真实目录结构是安装根目录下包含 `bin/`、`Licenses/`、`installerResources/` 和维护工具,业务主程序位于 `bin/SimCAE.exe`。当前交付配置按“SDK 在 bin、发布整个安装根目录”工作:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SimCAE/
|
||||||
|
bin/
|
||||||
|
Launcher.exe
|
||||||
|
Updater.exe
|
||||||
|
Bootstrap.exe
|
||||||
|
SimCAE.exe
|
||||||
|
config/
|
||||||
|
app_config.json
|
||||||
|
manifest_public_key.pem
|
||||||
|
Licenses/
|
||||||
|
installerResources/
|
||||||
|
maintenancetool.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
客户端配置使用 `install_root=..`、`main_executable=SimCAE.exe`,服务端配置使用 `RELEASE_MAIN_EXECUTABLE=bin/SimCAE.exe`。后台发布版本时可以选择整个 `SimCAE/` 安装根目录,也可以上传压缩好的 `SimCAE.zip` / `SimCAE.tar.gz` 等发布包;不要只选择或只压缩 `bin/`。升级事务目录固定放在 `Launcher.exe` 所在目录下,也就是 `SimCAE/bin/update/`。
|
||||||
|
|
||||||
|
## 十、运行目录权限说明
|
||||||
|
|
||||||
|
当前客户端 SDK 默认把运行时状态写在 `Launcher.exe` 所在目录下,包括 `config/app_config.json`、`config/client_identity.dat`、`config/local_state.json`、`config/version_policy.dat`、`update/` 和 `update_temp/`。当 SDK 放在 `SimCAE/bin` 时,更新事务、备份、暂存文件、下载断点和 Manifest 缓存都在 `SimCAE/bin/update/`,不会再在 `SimCAE/` 根目录生成顶层 `update/`。因此联调目录必须允许当前 Windows 用户写入。
|
||||||
|
|
||||||
|
如果把软件放在 `C:\Program Files\SimCAE\bin` 并用普通用户启动,Windows 会拒绝写入,首次启动可能提示 `cannot save installation id`。当前建议先在 `D:\SimCAE_Release` 或桌面目录联调;正式要安装到 `Program Files` 时,需要补管理员提权、Windows 服务,或将运行时状态迁移到 `ProgramData` / `AppData`。
|
||||||
|
|
||||||
|
## 十一、后续待办
|
||||||
|
|
||||||
- 等业务开发者按 SDK 文档改造 SimCAE 源码。
|
- 等业务开发者按 SDK 文档改造 SimCAE 源码。
|
||||||
- 用改造后的 SimCAE Release 目录做完整升级联调。
|
- 用改造后的 SimCAE Release 目录做完整升级联调。
|
||||||
- 确认直接启动 `SimCAE.exe` 会被拒绝,只能通过 `Launcher.exe` 启动。
|
- 确认直接启动 `SimCAE.exe` 会被拒绝,只能通过 `Launcher.exe` 启动。
|
||||||
- 验证升级成功、升级失败回滚、Manifest 验签失败拦截。
|
- 验证升级成功、升级失败回滚、Manifest 验签失败拦截。
|
||||||
- 如需迁移现有服务数据,补充 `runtime/` 和 `minio_data/` 的备份恢复流程。
|
- 如需迁移现有服务数据,补充 `runtime/` 和 `minio_data/` 的备份恢复流程。
|
||||||
- 生产部署前重新生成强随机 token 和 MinIO 密码。
|
- 交付配置已提供可直接试跑的默认 token 和 MinIO 账号密码;正式生产环境建议替换为客户自己的强随机值。
|
||||||
- 生产私钥和测试私钥建议分开管理。
|
- 生产私钥和测试私钥建议分开管理。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -294,14 +327,14 @@ manifest_public_key.pem
|
|||||||
|
|
||||||
自动升级系统的 Windows 在线更新主链路已经基本打通,并且已经从“能升级”推进到“可控、安全、可回滚、可离线导入”的阶段。客户端具备 Launcher、Updater、MainApp、Bootstrap 四个程序;服务端具备 FastAPI、SQLite、MinIO、本地回退存储、版本发布、Manifest、下载授权、升级结果上报、版本策略、设备身份、License 授权、动态渠道、离线更新包、下载日志、管理员审计日志和管理网页。客户端可以检查版本、验证 RSA 签名、差异下载、断点续传、备份旧文件、通过 Bootstrap 替换被占用文件、删除废弃文件、启动新版本并等待健康确认;失败时可以进入自动回滚流程。
|
自动升级系统的 Windows 在线更新主链路已经基本打通,并且已经从“能升级”推进到“可控、安全、可回滚、可离线导入”的阶段。客户端具备 Launcher、Updater、MainApp、Bootstrap 四个程序;服务端具备 FastAPI、SQLite、MinIO、本地回退存储、版本发布、Manifest、下载授权、升级结果上报、版本策略、设备身份、License 授权、动态渠道、离线更新包、下载日志、管理员审计日志和管理网页。客户端可以检查版本、验证 RSA 签名、差异下载、断点续传、备份旧文件、通过 Bootstrap 替换被占用文件、删除废弃文件、启动新版本并等待健康确认;失败时可以进入自动回滚流程。
|
||||||
|
|
||||||
SimCAE 崩溃报告后端已经完成第一阶段核心接口:接收 SimCAECrashReporter.exe 上传的 metadata.json、crash.dmp 和可选 attachments.zip;根据 clientReportId 做幂等去重;校验 minidump SHA-256;保存原始文件;返回稳定 reportId;接收 CI/发布流程上传的 symbols.zip;并提供管理 token 查询和私有文件下载入口。自动符号化、聚合统计和专门后台页面属于第二阶段。
|
SimCAE 崩溃报告后端已经完成第一阶段核心接口:接收 SimCAECrashReporter.exe 上传的 metadata.json、crash.dmp 和可选 attachments.zip;根据 clientReportId 做幂等去重;校验 minidump SHA-256;保存原始文件;返回稳定 reportId;接收 CI/发布流程上传的 symbols.zip;并提供管理 token 查询和私有文件下载入口。管理后台已经新增基础“崩溃报告”页面,可查看报告列表并下载 metadata、dmp、attachments 和 server.json。自动符号化、聚合统计、告警和问题分派属于后续增强。
|
||||||
|
|
||||||
按两份需求文档综合判断:
|
按两份需求文档综合判断:
|
||||||
|
|
||||||
1. 自动升级 Demo 主链路:约 90%~95%,剩余主要是系统性故障测试和插件真实接口加载。
|
1. 自动升级 Demo 主链路:约 90%~95%,剩余主要是系统性故障测试和插件真实接口加载。
|
||||||
2. 自动升级完整设计文档:约 75%~80%,剩余主要是正式管理员体系、限流、代码签名、灰度、多平台、插件接口版本准入和生产化测试。
|
2. 自动升级完整设计文档:约 75%~80%,剩余主要是正式管理员体系、限流、代码签名、灰度、多平台、插件接口版本准入和生产化测试。
|
||||||
3. Crash Report 第一阶段接口:约 85%~90%,已经能支撑客户端联调;剩余是 HTTPS 部署、限流、保留期清理和管理页面整合。
|
3. Crash Report 第一阶段接口:约 90%~95%,已经按需求文档完成真实 HTTP 联调;剩余是 HTTPS 部署、限流和保留期清理。
|
||||||
4. Crash Report 完整平台:约 45%~55%,因为自动符号化、统计聚合、告警和问题分派还未做。
|
4. Crash Report 完整平台:约 50%~60%,因为自动符号化、统计聚合、告警和问题分派还未做。
|
||||||
|
|
||||||
当前最主要的剩余工作不再是普通在线更新,而是把已完成能力做实机回归、生产化安全加固,并把 SimCAE 崩溃上报客户端接入进来。
|
当前最主要的剩余工作不再是普通在线更新,而是把已完成能力做实机回归、生产化安全加固,并把 SimCAE 崩溃上报客户端接入进来。
|
||||||
|
|
||||||
@@ -404,6 +437,8 @@ SimCAE 崩溃报告接口:
|
|||||||
5. Manifest、MinIO 和客户端安装过程都保留相对路径。
|
5. Manifest、MinIO 和客户端安装过程都保留相对路径。
|
||||||
6. 检查 MainApp.exe 是否位于发布根级。
|
6. 检查 MainApp.exe 是否位于发布根级。
|
||||||
7. 排除 Bootstrap、运行时配置、状态文件、策略缓存、PDB、ILK 和更新临时目录。
|
7. 排除 Bootstrap、运行时配置、状态文件、策略缓存、PDB、ILK 和更新临时目录。
|
||||||
|
8. 支持上传压缩发布包,服务端解压后按同一套路径、主程序、文件数量和空间规则校验,并自动过滤 `update/`、`update_temp/`、构建目录、运行时配置和调试产物。
|
||||||
|
9. 压缩包可多一层顶层目录,例如 `SimCAE/bin/SimCAE.exe` 会自动归一为 `bin/SimCAE.exe`。
|
||||||
|
|
||||||
2.5 Manifest
|
2.5 Manifest
|
||||||
|
|
||||||
@@ -450,7 +485,7 @@ SimCAE 崩溃报告接口:
|
|||||||
|
|
||||||
1. SHA 相同的文件跳过下载。
|
1. SHA 相同的文件跳过下载。
|
||||||
2. HTTP Range 断点续传。
|
2. HTTP Range 断点续传。
|
||||||
3. 使用 update/download_cache/<sha256>.part 保存片段。
|
3. 使用运行目录下的 `update/download_cache/<sha256>.part` 保存片段;SimCAE 场景中即 `SimCAE/bin/update/download_cache/`。
|
||||||
4. Updater 重启后仍可继续未完成文件。
|
4. Updater 重启后仍可继续未完成文件。
|
||||||
5. 单文件最多自动重试四次。
|
5. 单文件最多自动重试四次。
|
||||||
6. 使用递增等待时间重试。
|
6. 使用递增等待时间重试。
|
||||||
@@ -473,7 +508,7 @@ SimCAE 崩溃报告接口:
|
|||||||
|
|
||||||
2.9 升级事务状态机
|
2.9 升级事务状态机
|
||||||
|
|
||||||
已实现 update/upgrade_state.json,包含:
|
已实现运行目录下的 `update/upgrade_state.json`,SimCAE 场景中即 `SimCAE/bin/update/upgrade_state.json`,包含:
|
||||||
|
|
||||||
1. transaction_id。
|
1. transaction_id。
|
||||||
2. from_version。
|
2. from_version。
|
||||||
@@ -557,6 +592,8 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
|
|||||||
10. 降级目标协议低于当前客户端协议时,服务端在安装前返回 rollback_denied。
|
10. 降级目标协议低于当前客户端协议时,服务端在安装前返回 rollback_denied。
|
||||||
11. 管理后台支持查看和修正历史版本的协议标签。
|
11. 管理后台支持查看和修正历史版本的协议标签。
|
||||||
|
|
||||||
|
这里的“客户端协议”不是 HTTP 协议,也不是软件版本号,而是 Launcher/Updater 支持的升级机制能力编号。当前默认是 `3`,代表支持一次性启动票据、健康检查、策略校验、受控降级等当前客户端机制;普通发布保持默认值即可,只有未来客户端升级机制发生不兼容变化时才需要调整。
|
||||||
|
|
||||||
2.14 离线运行基础
|
2.14 离线运行基础
|
||||||
|
|
||||||
已实现:
|
已实现:
|
||||||
@@ -581,15 +618,16 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
|
|||||||
2. 修改管理员令牌。
|
2. 修改管理员令牌。
|
||||||
3. 创建和选择应用。
|
3. 创建和选择应用。
|
||||||
4. 选择软件根目录发布。
|
4. 选择软件根目录发布。
|
||||||
5. stable、preview、dev 渠道选择。
|
5. 切换到“压缩发布包”模式上传压缩包发布,支持 zip、tar.gz、tgz、tar.bz2、tbz2、rar。
|
||||||
6. 版本列表。
|
6. stable、preview、dev 渠道选择。
|
||||||
7. 设置最新版本。
|
7. 版本列表。
|
||||||
8. 删除版本和云端文件。
|
8. 设置最新版本。
|
||||||
9. 版本策略读取和保存。
|
9. 删除版本和云端文件。
|
||||||
10. 升级日志。
|
10. 版本策略读取和保存。
|
||||||
11. 调试输出。
|
11. 升级日志。
|
||||||
12. 发布文件预览、大小和状态提示。
|
12. 调试输出。
|
||||||
13. 页面美化和响应式布局。
|
13. 发布文件预览、大小和状态提示。
|
||||||
|
14. 页面美化和响应式布局。
|
||||||
|
|
||||||
|
|
||||||
2.16 一次性短期启动票据
|
2.16 一次性短期启动票据
|
||||||
@@ -627,12 +665,14 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
|
|||||||
3. 服务端逐请求验证设备凭证、授权状态、设备禁用状态、app_id 和 channel。
|
3. 服务端逐请求验证设备凭证、授权状态、设备禁用状态、app_id 和 channel。
|
||||||
4. License 授权:licenses 和 license_devices 表,支持有效期、启用/禁用、最大设备数和设备占用。
|
4. License 授权:licenses 和 license_devices 表,支持有效期、启用/禁用、最大设备数和设备占用。
|
||||||
5. 授权密钥只保存 SHA-256,明文只在创建时返回一次。
|
5. 授权密钥只保存 SHA-256,明文只在创建时返回一次。
|
||||||
6. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。
|
6. Launcher 首次启动时如果没有 License,会弹窗要求用户输入并自动写入配置;当前实现会先检查 `license_key` 配置项,即使本地已有 `client_identity.dat`,清空 `license_key` 仍会触发输入框。
|
||||||
7. 发布、策略、授权、设备登记和更新检查统一校验渠道。
|
7. License 错误、过期、禁用、设备数达到上限或设备身份与授权不匹配时,Launcher 会清理旧设备身份并引导用户重新输入 License。
|
||||||
8. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。
|
8. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。
|
||||||
9. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。
|
9. 发布、策略、授权、设备登记和更新检查统一校验渠道。
|
||||||
10. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。
|
10. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。
|
||||||
11. 管理员审计日志:记录 /admin 写操作、管理员令牌指纹、路径、结果、状态码、IP 和 User-Agent。
|
11. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。
|
||||||
|
12. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。
|
||||||
|
13. 管理员审计日志:记录 /admin 写操作、管理员令牌指纹、路径、结果、状态码、IP 和 User-Agent。
|
||||||
|
|
||||||
2.19 SimCAE Crash Report 后端第一阶段
|
2.19 SimCAE Crash Report 后端第一阶段
|
||||||
|
|
||||||
@@ -654,8 +694,11 @@ Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。
|
|||||||
14. POST /api/v1/symbols 接收 CI/发布流程上传的 metadata.json 和 symbols.zip。
|
14. POST /api/v1/symbols 接收 CI/发布流程上传的 metadata.json 和 symbols.zip。
|
||||||
15. crash_symbol_uploads 表按 product、appVersion、gitCommit、buildType、platform 索引符号包。
|
15. crash_symbol_uploads 表按 product、appVersion、gitCommit、buildType、platform 索引符号包。
|
||||||
16. 已配置 CRASH_STORAGE_ROOT,Docker 部署时落到 /data/crash_storage 持久化目录。
|
16. 已配置 CRASH_STORAGE_ROOT,Docker 部署时落到 /data/crash_storage 持久化目录。
|
||||||
|
17. 管理后台新增“崩溃报告”页面,登录后可查看 report_id、版本、gitCommit、构建类型、渠道、异常码、崩溃时间、接收时间、来源 IP 和文件大小。
|
||||||
|
18. 管理后台可下载单条崩溃报告的 metadata.json、crash.dmp、attachments.zip 和 server.json,并写入 crash_file_access_logs。
|
||||||
|
19. 已按需求文档的 curl 联调思路完成真实 HTTP 测试:上传成功、重复上传幂等、内容冲突 409、错误 token 401、缺 metadata 400、minidump SHA-256 错误 400、管理查询、原始文件下载、符号包上传和符号包重复上传均通过。
|
||||||
|
|
||||||
说明:第一阶段只做“可靠接收、校验、保存、索引和查询”。自动符号化、聚合统计、告警、问题分派和专门后台页面属于第二阶段。
|
说明:第一阶段已经做到“可靠接收、校验、保存、索引、查询和后台查看/下载”。自动符号化、聚合统计、告警和问题分派属于后续增强。
|
||||||
|
|
||||||
============================================================
|
============================================================
|
||||||
三、部分实现的功能
|
三、部分实现的功能
|
||||||
@@ -757,7 +800,7 @@ RSA 签名已经实现,但仍缺少:
|
|||||||
|
|
||||||
1. 自动符号化,把 crash.dmp 转成可读调用栈。
|
1. 自动符号化,把 crash.dmp 转成可读调用栈。
|
||||||
2. 根据 appVersion、gitCommit、buildType 自动匹配 symbols.zip。
|
2. 根据 appVersion、gitCommit、buildType 自动匹配 symbols.zip。
|
||||||
3. 崩溃报告列表、搜索、下载和统计后台页面。
|
3. 崩溃报告高级搜索、筛选、统计和详情页面。
|
||||||
4. 按异常码、版本、GPU、命令、项目等维度聚合。
|
4. 按异常码、版本、GPU、命令、项目等维度聚合。
|
||||||
5. 保留期清理,例如 90 天或 180 天。
|
5. 保留期清理,例如 90 天或 180 天。
|
||||||
6. 上传限流和异常峰值告警。
|
6. 上传限流和异常峰值告警。
|
||||||
@@ -1213,7 +1256,7 @@ SDK 是 Software Development Kit,中文通常叫“软件开发工具包”。
|
|||||||
它不是单个 exe,也不只是源码,而是一套让别人能把你的能力接到自己软件里的交付包。一个合格 SDK 通常包含:
|
它不是单个 exe,也不只是源码,而是一套让别人能把你的能力接到自己软件里的交付包。一个合格 SDK 通常包含:
|
||||||
|
|
||||||
1. 可直接使用的二进制文件,例如 Launcher.exe、Updater.exe、Bootstrap.exe。
|
1. 可直接使用的二进制文件,例如 Launcher.exe、Updater.exe、Bootstrap.exe。
|
||||||
2. 必要的运行时 DLL,例如 Qt、OpenSSL、平台插件等。
|
2. 必要的运行时文件。对 SimCAE 这种自身已带 Qt 的软件,SDK 默认不再携带 Qt DLL,避免覆盖业务软件原有运行库。
|
||||||
3. 配置模板,例如 app_config.example.json。
|
3. 配置模板,例如 app_config.example.json。
|
||||||
4. 接入文档,例如如何配置 app_id、channel、api_base_url、license_key。
|
4. 接入文档,例如如何配置 app_id、channel、api_base_url、license_key。
|
||||||
5. 打包脚本,例如 package-client.ps1。
|
5. 打包脚本,例如 package-client.ps1。
|
||||||
@@ -1254,38 +1297,26 @@ SDK 是 Software Development Kit,中文通常叫“软件开发工具包”。
|
|||||||
建议最终交付类似下面的目录:
|
建议最终交付类似下面的目录:
|
||||||
|
|
||||||
UpdateClientSDK/
|
UpdateClientSDK/
|
||||||
README.md
|
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||||
docs/
|
sdk_manifest.json
|
||||||
接入说明.md
|
|
||||||
服务端接口说明.md
|
|
||||||
常见错误.md
|
|
||||||
bin/
|
bin/
|
||||||
Launcher.exe
|
Launcher.exe
|
||||||
Updater.exe
|
Updater.exe
|
||||||
Bootstrap.exe
|
Bootstrap.exe
|
||||||
Qt5Core.dll
|
|
||||||
Qt5Gui.dll
|
|
||||||
Qt5Widgets.dll
|
|
||||||
Qt5Network.dll
|
|
||||||
platforms/qwindows.dll
|
|
||||||
openssl 相关 DLL
|
|
||||||
config/
|
config/
|
||||||
app_config.example.json
|
app_config.json
|
||||||
manifest_public_key.pem
|
manifest_public_key.pem
|
||||||
scripts/
|
scripts/
|
||||||
|
install-sdk.ps1
|
||||||
package-client.ps1
|
package-client.ps1
|
||||||
samples/
|
package-sdk.ps1
|
||||||
minimal_app/
|
|
||||||
MainApp.exe 或示例源码
|
|
||||||
app_config.json 示例
|
|
||||||
|
|
||||||
对接方真正拿到后,通常只需要做这些事:
|
对接方真正拿到后,通常只需要做这些事:
|
||||||
|
|
||||||
1. 把自己的主程序和依赖 DLL 放到发布目录。
|
1. 把 SDK 的 Launcher、Updater、Bootstrap 放到业务软件运行目录。
|
||||||
2. 设置 app_config.json:server 地址、app_id、channel、当前版本、license_key、主程序名等。
|
2. 设置 app_config.json:server 地址、app_id、channel、当前版本、主程序名等。`license_key` 可以预先填入;如果为空,用户首次启动 Launcher 时会弹窗粘贴授权密钥。
|
||||||
3. 以后让用户启动 Launcher.exe。
|
3. 以后让用户启动 Launcher.exe。
|
||||||
4. 在管理后台创建应用、License、渠道和发布版本。
|
4. 在管理后台创建应用、License、渠道和发布版本。
|
||||||
5. 发布新版本时选择干净的 Release 输出目录。
|
5. 发布新版本时选择干净的 Release 输出目录,或切换到“压缩发布包”模式上传 zip/tar.gz/tar.bz2/rar 等压缩发布包。
|
||||||
|
|
||||||
10.4 当前已有 package-client.ps1 的作用
|
10.4 当前已有 package-client.ps1 的作用
|
||||||
|
|
||||||
@@ -1357,7 +1388,7 @@ Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器
|
|||||||
1. 使用 python:3.12-slim 作为基础镜像。
|
1. 使用 python:3.12-slim 作为基础镜像。
|
||||||
2. 安装 server/requirements.txt 里的 FastAPI、MinIO、cryptography 等依赖。
|
2. 安装 server/requirements.txt 里的 FastAPI、MinIO、cryptography 等依赖。
|
||||||
3. 拷贝 main.py、db.py、minio_tool.py、tables.sql。
|
3. 拷贝 main.py、db.py、minio_tool.py、tables.sql。
|
||||||
4. 拷贝 client/admin.html 到容器内 /app/admin.html。
|
4. 拷贝 server/admin.html 到容器内 /app/admin.html。
|
||||||
5. 使用非 root 用户 updateapp 运行。
|
5. 使用非 root 用户 updateapp 运行。
|
||||||
6. 暴露 8000 端口。
|
6. 暴露 8000 端口。
|
||||||
7. 提供 healthcheck。
|
7. 提供 healthcheck。
|
||||||
@@ -1469,7 +1500,7 @@ Docker 是给“服务端部署人员”用的。
|
|||||||
第二步:准备客户端 SDK 包。
|
第二步:准备客户端 SDK 包。
|
||||||
|
|
||||||
1. 确认 client/out/bin 是 Release 输出目录。
|
1. 确认 client/out/bin 是 Release 输出目录。
|
||||||
2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe、Qt DLL、platforms/qwindows.dll。
|
2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe。SimCAE 场景下不要把 SDK 自带 Qt DLL 覆盖到 SimCAE 的 bin 目录。
|
||||||
3. 确认 config/manifest_public_key.pem 是服务端私钥对应的公钥。
|
3. 确认 config/manifest_public_key.pem 是服务端私钥对应的公钥。
|
||||||
4. 填好 client/config/app_config.example.json 里的示例字段。
|
4. 填好 client/config/app_config.example.json 里的示例字段。
|
||||||
5. 在 Windows PowerShell 执行:
|
5. 在 Windows PowerShell 执行:
|
||||||
@@ -1495,7 +1526,7 @@ cd client
|
|||||||
|
|
||||||
第五步:补接入方文档和 Demo。
|
第五步:补接入方文档和 Demo。
|
||||||
|
|
||||||
1. 把 client/SDK_README.md 随 SDK 一起交付。
|
1. SDK 根目录只保留一个 Word 接入说明作为入口,打开包后一眼能看到。
|
||||||
2. 准备一个最小 MainApp 示例,演示 --ticket-file 和 --health-file。
|
2. 准备一个最小 MainApp 示例,演示 --ticket-file 和 --health-file。
|
||||||
3. 准备一份常见错误说明。
|
3. 准备一份常见错误说明。
|
||||||
4. 准备一份服务端 Docker 部署说明。
|
4. 准备一份服务端 Docker 部署说明。
|
||||||
|
|||||||
Reference in New Issue
Block a user