chore: prepare repository for submodule split
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Operating systems and editors
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.vscode/
|
||||
.idea/
|
||||
.vs/
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.bak
|
||||
*.swp
|
||||
*.log
|
||||
*~
|
||||
|
||||
# Private requirement / handover documents
|
||||
*.pdf
|
||||
*.doc
|
||||
*.docx
|
||||
|
||||
# C/C++ generated artifacts
|
||||
*.obj
|
||||
*.o
|
||||
*.pdb
|
||||
*.ilk
|
||||
*.idb
|
||||
*.tlog
|
||||
*.lastbuildstate
|
||||
*.exp
|
||||
*.lib
|
||||
*.dll
|
||||
*.exe
|
||||
|
||||
# Build outputs
|
||||
out/
|
||||
build/
|
||||
build-*/
|
||||
cmake-build-*/
|
||||
.cmake/
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
CMakeSettings.json
|
||||
CMakeUserPresets.json
|
||||
Testing/
|
||||
|
||||
# Third-party/business binary drops and generated packages
|
||||
App/
|
||||
dist/
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
*.7z
|
||||
*.rar
|
||||
|
||||
# Local runtime state and credentials
|
||||
config/app_config.json
|
||||
config/client_identity.dat
|
||||
config/local_state.json
|
||||
config/version_policy.dat
|
||||
config/*.local.json
|
||||
config/*private*.pem
|
||||
config/*private*.key
|
||||
update/
|
||||
update_temp/
|
||||
@@ -28,6 +28,40 @@ QString ConfigHelper::configPath() const
|
||||
return m_configPath;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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)
|
||||
{
|
||||
Q_UNUSED(section);
|
||||
m_error.clear();
|
||||
|
||||
QFile input(m_configPath);
|
||||
QJsonObject config;
|
||||
if (input.exists())
|
||||
{
|
||||
if (!input.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
|
||||
return false;
|
||||
}
|
||||
QJsonParseError parseError;
|
||||
const QByteArray raw = input.readAll();
|
||||
input.close();
|
||||
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||
{
|
||||
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
config = document.object();
|
||||
}
|
||||
|
||||
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);
|
||||
if (!output.open(QIODevice::WriteOnly))
|
||||
{
|
||||
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
|
||||
return false;
|
||||
}
|
||||
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||
if (output.write(payload) != payload.size())
|
||||
{
|
||||
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
|
||||
output.cancelWriting();
|
||||
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()
|
||||
@@ -103,6 +160,7 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||
copyText("Update", "request_timeout_ms", "5000");
|
||||
copyText("Update", "temp_folder", "update_temp");
|
||||
copyText("Update", "device_id");
|
||||
copyText("Runtime", "install_root", ".");
|
||||
copyText("Runtime", "main_executable", "MainApp.exe");
|
||||
copyText("Runtime", "launcher_executable", "Launcher.exe");
|
||||
copyText("Runtime", "updater_executable", "Updater.exe");
|
||||
|
||||
@@ -8,9 +8,15 @@ public:
|
||||
QString getValue(const QString& section, const QString& key) const;
|
||||
bool setValue(const QString& section, const QString& key, const QString& value);
|
||||
QString configPath() const;
|
||||
QString installRoot() const;
|
||||
QString runtimeRoot() const;
|
||||
QString updateRoot() const;
|
||||
QString runtimeRelativePath() const;
|
||||
QString lastError() const;
|
||||
|
||||
private:
|
||||
ConfigHelper();
|
||||
bool migrateLegacyIniIfNeeded();
|
||||
QString m_configPath;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include <QSaveFile>
|
||||
#include <QSysInfo>
|
||||
#include <QUuid>
|
||||
#include <QTimer>
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.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::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
|
||||
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
|
||||
QString installation=ConfigHelper::instance().getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!ConfigHelper::instance().setValue("Device","installation_id",installation)){m_error="cannot save installation id";return false;}}
|
||||
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}};
|
||||
QNetworkAccessManager manager;QNetworkRequest req{QUrl(base+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);loop.exec();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error="cannot save device credential";return false;}if(!loadAndVerify(appId,channel))return false;if(!ConfigHelper::instance().setValue("Update","device_id",m_deviceId)){m_error="cannot save server device id";return false;}return true;
|
||||
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QApplication>
|
||||
#include <QTimer>
|
||||
|
||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
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);
|
||||
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);
|
||||
timer.start(timeoutMs);
|
||||
loop.exec();
|
||||
timer.stop();
|
||||
|
||||
int retCode = 0;
|
||||
QJsonObject retObj;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "IntegrityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
@@ -28,10 +29,19 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||
{
|
||||
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",
|
||||
"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);
|
||||
}
|
||||
|
||||
@@ -49,7 +59,10 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||
#else
|
||||
QFile keyFile(QDir(m_installDir).filePath("config/manifest_public_key.pem"));
|
||||
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; }
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
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)
|
||||
{
|
||||
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");
|
||||
if (!QFile::exists(cachePath))
|
||||
cachePath = legacyCachePath;
|
||||
QFile cache(cachePath);
|
||||
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
|
||||
QJsonParseError wrapperError;
|
||||
@@ -121,8 +138,12 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
const QString fullPath = it.next();
|
||||
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
||||
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/")
|
||||
|| runtimeProtectedPath(relative)) continue;
|
||||
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
||||
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
|
||||
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
|
||||
m_error = "undeclared executable or plugin: " + relative; return false;
|
||||
|
||||
+88
-11
@@ -4,6 +4,8 @@
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QProgressDialog>
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include <QTextCodec>
|
||||
#include "UpdateLogic.h"
|
||||
#include "../Common/ConfigHelper.h"
|
||||
@@ -13,6 +15,7 @@
|
||||
#include "../Common/DeviceIdentityHelper.h"
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QDir>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
@@ -30,16 +33,81 @@ int main(int argc, char* argv[])
|
||||
progress.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
const QString appDir = QApplication::applicationDirPath();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
DeviceIdentityHelper identity(QApplication::applicationDirPath());
|
||||
if (!identity.ensureIssued(config.getValue("Server", "api_base_url"),
|
||||
config.getValue("Server", "client_token"),
|
||||
config.getValue("App", "app_id"),
|
||||
config.getValue("App", "channel"),
|
||||
config.getValue("License", "license_key"))) {
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, "设备身份验证失败", identity.errorString());
|
||||
return -1;
|
||||
const auto isLicenseError = [](const QString& errorText) {
|
||||
const QString text = errorText.toLower();
|
||||
return text.contains("license")
|
||||
|| errorText.contains("授权")
|
||||
|| errorText.contains("过期")
|
||||
|| errorText.contains("设备数量已达到上限")
|
||||
|| errorText.contains("已绑定其他授权");
|
||||
};
|
||||
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;
|
||||
@@ -57,13 +125,22 @@ int main(int argc, char* argv[])
|
||||
progress.close();
|
||||
const QJsonValue detail = response.value("detail");
|
||||
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
|
||||
QMessageBox::critical(nullptr, "授权被拒绝", message.isEmpty() ? "设备或 License 授权无效。" : message);
|
||||
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;
|
||||
}
|
||||
|
||||
const QString launchToken = config.getValue("App", "launch_token");
|
||||
const QString currentVersion = config.getValue("App", "current_version");
|
||||
const QString appDir = QApplication::applicationDirPath();
|
||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||
const QString value = config.getValue("Runtime", key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
|
||||
@@ -51,6 +51,7 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
QString appDir = QApplication::applicationDirPath();
|
||||
const QString installRoot = config.installRoot();
|
||||
DeviceIdentityHelper identity(appDir);
|
||||
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
|
||||
{
|
||||
@@ -89,7 +90,7 @@ int main(int argc, char* argv[])
|
||||
return -1;
|
||||
}
|
||||
|
||||
IntegrityHelper integrity(appDir);
|
||||
IntegrityHelper integrity(installRoot);
|
||||
if (!integrity.verifyInstalledVersion(
|
||||
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
||||
config.getValue("App", "current_version")))
|
||||
|
||||
+69
-10
@@ -17,12 +17,63 @@ SDK 核心程序:
|
||||
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。
|
||||
2. 创建或确认渠道,例如 `stable`。
|
||||
3. 创建 License,把生成的 `license_key` 填到客户端配置。
|
||||
4. 把业务程序和依赖 DLL 放到同一个发布目录。
|
||||
5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 和运行时 DLL 放到该目录。
|
||||
6. 把 `config/app_config.example.json` 复制成 `config/app_config.json` 并修改字段。
|
||||
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`。
|
||||
5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。
|
||||
6. 把 `config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
|
||||
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
|
||||
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 关键字段
|
||||
|
||||
```json
|
||||
@@ -39,6 +90,7 @@ SDK 核心程序:
|
||||
"request_timeout_ms": "5000",
|
||||
"temp_folder": "update_temp",
|
||||
"device_id": "",
|
||||
"install_root": "..",
|
||||
"main_executable": "SimCAE.exe",
|
||||
"launcher_executable": "Launcher.exe",
|
||||
"updater_executable": "Updater.exe",
|
||||
@@ -59,7 +111,8 @@ SDK 核心程序:
|
||||
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。
|
||||
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
|
||||
- `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`:升级后等待业务程序写健康标记的时间。
|
||||
|
||||
## 业务主程序需要配合什么
|
||||
@@ -92,15 +145,19 @@ cd client
|
||||
-SdkVersion 0.1.0
|
||||
```
|
||||
|
||||
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。
|
||||
|
||||
生成结果:
|
||||
|
||||
```text
|
||||
dist/UpdateClientSDK/
|
||||
README.md
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||
sdk_manifest.json
|
||||
bin/
|
||||
config/
|
||||
app_config.json
|
||||
manifest_public_key.pem
|
||||
scripts/
|
||||
samples/
|
||||
```
|
||||
|
||||
把 `UpdateClientSDK.zip` 发给接入方即可。
|
||||
@@ -123,7 +180,9 @@ cd client
|
||||
## 常见错误
|
||||
|
||||
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
|
||||
2. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。
|
||||
3. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
|
||||
4. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
|
||||
5. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
|
||||
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
|
||||
3. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。
|
||||
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
|
||||
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
|
||||
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
|
||||
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
#include <QSet>
|
||||
#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)
|
||||
: m_installDir(QDir::cleanPath(installDir)),
|
||||
m_updateDir(QDir(installDir).filePath("update")),
|
||||
m_stateFile(QDir(installDir).filePath("update/upgrade_state.json")),
|
||||
m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)),
|
||||
m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")),
|
||||
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
|
||||
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; }
|
||||
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);
|
||||
if (!file.exists()) return true;
|
||||
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
class UpdateTransaction
|
||||
{
|
||||
public:
|
||||
UpdateTransaction(const QString& installDir, const QString& fromVersion,
|
||||
UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
|
||||
const QString& toVersion, int manifestId);
|
||||
|
||||
bool initialize();
|
||||
@@ -29,7 +29,7 @@ public:
|
||||
QStringList obsoletePaths() const;
|
||||
QString healthFile() const;
|
||||
|
||||
static bool recoverInterrupted(const QString& installDir,
|
||||
static bool recoverInterrupted(const QString& installDir, const QString& updateDir,
|
||||
QString* restoredVersion = nullptr,
|
||||
QString* errorMessage = nullptr);
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
||||
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
||||
}
|
||||
|
||||
const QSet<QString> protectedPaths{
|
||||
QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("launcher.exe"),
|
||||
QStringLiteral("updater.exe"),
|
||||
@@ -273,6 +273,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
||||
QStringLiteral("config/client_identity.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;
|
||||
QSet<QString> seen;
|
||||
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
|
||||
{
|
||||
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
static const QSet<QString> protectedPaths{
|
||||
QSet<QString> protectedPaths{
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("client.ini"),
|
||||
QStringLiteral("config/app_config.json"),
|
||||
@@ -558,6 +569,16 @@ bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
||||
QStringLiteral("config/client_identity.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);
|
||||
}
|
||||
|
||||
@@ -576,7 +597,7 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
||||
const QStringList& obsoletePaths) const
|
||||
{
|
||||
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) {
|
||||
const QString relativePath = QDir::fromNativeSeparators(item.path);
|
||||
if (isRuntimeProtectedPath(relativePath)) continue;
|
||||
@@ -609,7 +630,7 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
||||
QDir stagingDir(tempDir);
|
||||
if (!FileHelper::createDir(tempDir))
|
||||
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))
|
||||
return false;
|
||||
QSet<QString> activePartialNames;
|
||||
@@ -727,4 +748,4 @@ void UpdaterLogic::reportResult(const QString& deviceId,
|
||||
QList<FileDownloadItem> UpdaterLogic::getFileList() const
|
||||
{
|
||||
return m_fileItems;
|
||||
}
|
||||
}
|
||||
|
||||
+18
-12
@@ -59,16 +59,19 @@ int main(int argc, char* argv[])
|
||||
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
||||
}
|
||||
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
||||
const QString targetDir = QApplication::applicationDirPath();
|
||||
const QString runtimeDir = QApplication::applicationDirPath();
|
||||
|
||||
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")) {
|
||||
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
|
||||
return -1;
|
||||
}
|
||||
QString fromVersion = config.getValue("App", "current_version");
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
PolicyHelper offlinePolicy(targetDir);
|
||||
PolicyHelper offlinePolicy(runtimeDir);
|
||||
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
||||
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
||||
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
|
||||
@@ -87,7 +90,7 @@ int main(int argc, char* argv[])
|
||||
if (!resumingFromBootstrap) {
|
||||
QString restoredVersion;
|
||||
QString recoveryError;
|
||||
if (!UpdateTransaction::recoverInterrupted(targetDir, &restoredVersion, &recoveryError)) {
|
||||
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
||||
QMessageBox::critical(nullptr, "更新恢复失败",
|
||||
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
||||
return -1;
|
||||
@@ -111,7 +114,7 @@ int main(int argc, char* argv[])
|
||||
progress.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId);
|
||||
UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId);
|
||||
const auto setProgress = [&](int value, const QString& message) {
|
||||
progress.setValue(value);
|
||||
progress.setLabelText(message);
|
||||
@@ -160,9 +163,9 @@ int main(int argc, char* argv[])
|
||||
bool timeoutOk = false;
|
||||
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
|
||||
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
|
||||
const QString mainAppPath = QDir(targetDir).filePath(mainExecutable);
|
||||
const QString updaterPath = QDir(targetDir).filePath(updaterExecutable);
|
||||
const QString bootstrapPath = QDir(targetDir).filePath(bootstrapExecutable);
|
||||
const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable);
|
||||
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
|
||||
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
|
||||
const QString launchToken = config.getValue("App", "launch_token");
|
||||
const auto launchMainApp = [&](const QString& healthFile = QString()) {
|
||||
QString ticketPath;
|
||||
@@ -180,7 +183,7 @@ int main(int argc, char* argv[])
|
||||
return started;
|
||||
};
|
||||
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 QStringList args{
|
||||
@@ -245,7 +248,7 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
|
||||
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 (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
||||
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
|
||||
@@ -282,7 +285,7 @@ int main(int argc, char* argv[])
|
||||
if (logic.getFileList().isEmpty())
|
||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
||||
|
||||
QStorageInfo storage(targetDir);
|
||||
QStorageInfo storage(updateDir);
|
||||
storage.refresh();
|
||||
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
||||
const qint64 availableBytes = storage.bytesAvailable();
|
||||
@@ -316,9 +319,12 @@ int main(int argc, char* argv[])
|
||||
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
|
||||
while (stagingFiles.hasNext())
|
||||
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) {
|
||||
if (path.compare(bootstrapExecutable, Qt::CaseInsensitive) == 0)
|
||||
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapExecutable), "bootstrap_self_update_blocked");
|
||||
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
||||
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked");
|
||||
}
|
||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
||||
|
||||
-1079
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
[Server]
|
||||
api_base_url=http://192.168.229.128:8000
|
||||
client_token=AuthKey202606Demo
|
||||
|
||||
[App]
|
||||
app_id=testadmin
|
||||
current_version=1.0.0
|
||||
channel=stable
|
||||
launch_token=UpdateSecret2026Demo
|
||||
|
||||
[Update]
|
||||
request_timeout_ms=5000
|
||||
temp_folder=update_temp
|
||||
device_id=test_device_001
|
||||
@@ -11,6 +11,7 @@
|
||||
"request_timeout_ms": "5000",
|
||||
"temp_folder": "update_temp",
|
||||
"device_id": "",
|
||||
"install_root": "..",
|
||||
"main_executable": "SimCAE.exe",
|
||||
"launcher_executable": "Launcher.exe",
|
||||
"updater_executable": "Updater.exe",
|
||||
|
||||
+35
-2
@@ -4,7 +4,9 @@ param(
|
||||
|
||||
[string]$ReleaseDir = (Get-Location).Path,
|
||||
|
||||
[switch]$OverwriteConfig
|
||||
[switch]$OverwriteConfig,
|
||||
|
||||
[switch]$IncludeQtRuntime
|
||||
)
|
||||
|
||||
$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"
|
||||
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
+64
-13
@@ -12,6 +12,31 @@ $source = (Resolve-Path $SourceDir).Path
|
||||
$config = (Resolve-Path $ConfigFile).Path
|
||||
$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 = @(
|
||||
"app_id", "channel", "api_base_url", "current_version",
|
||||
"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 = @(
|
||||
$settings.main_executable,
|
||||
$settings.launcher_executable,
|
||||
$settings.updater_executable,
|
||||
$settings.bootstrap_executable
|
||||
(Join-RelativePath $runtimeDirRelative $mainExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $launcherExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $updaterExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $bootstrapExecutable)
|
||||
)
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -43,15 +74,25 @@ if ($debugArtifacts) {
|
||||
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
|
||||
$nestedMain = Get-ChildItem $source -Recurse -File -Filter $settings.main_executable | Where-Object {
|
||||
$_.DirectoryName -ne $source
|
||||
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
||||
$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
|
||||
if ($nestedMain) {
|
||||
throw "Source directory contains a nested duplicate main executable. Use a clean Release root directory: $($nestedMain.FullName)"
|
||||
if ($duplicateMain) {
|
||||
throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)"
|
||||
}
|
||||
|
||||
$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)) {
|
||||
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")
|
||||
} | 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
|
||||
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 {
|
||||
$runtimeFile = Join-Path $outputConfigDir $_
|
||||
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
|
||||
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
||||
|
||||
|
||||
+33
-3
@@ -4,7 +4,8 @@ param(
|
||||
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
|
||||
[string]$SdkVersion = "0.1.0",
|
||||
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
|
||||
[switch]$IncludeDemoMainApp
|
||||
[switch]$IncludeDemoMainApp,
|
||||
[switch]$IncludeQtRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -46,11 +47,40 @@ $configDir = Join-Path $OutputDir "config"
|
||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$excludedTopLevel = @("config", "update", "update_temp")
|
||||
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
|
||||
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 {
|
||||
$_.Name -notin $excludedTopLevel
|
||||
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
|
||||
} | Copy-Item -Destination $binDir -Recurse -Force
|
||||
|
||||
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
||||
|
||||
Reference in New Issue
Block a user