chore: prepare repository for submodule split

This commit is contained in:
2026-07-09 09:00:51 +00:00
parent 9e545cb328
commit e9a2400c48
33 changed files with 4175 additions and 1545 deletions
+1 -1
View File
@@ -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
+8
View File
@@ -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
+70
View File
@@ -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/
+60 -2
View File
@@ -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");
+6
View File
@@ -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;
}; };
+10 -2
View File
@@ -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;
} }
+14
View File
@@ -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;
+25 -4
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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`
+10 -5
View File
@@ -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; }
+2 -2
View File
@@ -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);
+25 -4
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-14
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+11 -2
View File
@@ -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
View File
@@ -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
+59
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -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:
+4 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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:
-95
View File
@@ -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"
+263 -44
View File
@@ -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"
+84 -53
View File
@@ -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_ROOTDocker 部署时落到 /data/crash_storage 持久化目录。 16. 已配置 CRASH_STORAGE_ROOTDocker 部署时落到 /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.jsonserver 地址、app_id、channel、当前版本、license_key、主程序名等 2. 设置 app_config.jsonserver 地址、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 部署说明。