feat: 优化客户端跨平台配置和运行态管理
This commit is contained in:
@@ -45,6 +45,8 @@ static QString joinPath(const QString& root, const QString& relativePath)
|
||||
|
||||
static bool removeWithRetry(const QString& path)
|
||||
{
|
||||
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
|
||||
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
if (!QFileInfo::exists(path))
|
||||
return true;
|
||||
@@ -164,6 +166,8 @@ int main(int argc, char* argv[])
|
||||
rolledBack = rollback(installDir, backupDir, paths);
|
||||
success = false;
|
||||
} else {
|
||||
// Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
|
||||
// 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
|
||||
for (const PlanItem& item : items) {
|
||||
const QString& relativePath = item.relativePath;
|
||||
if (isBootstrapSelfPath(relativePath)) {
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ endif()
|
||||
|
||||
add_compile_definitions(HAVE_OPENSSL=1)
|
||||
|
||||
# 统一输出目录。Windows 保持历史 out/bin;Linux 单独输出,避免和 Windows DLL/PDB 混在一起。
|
||||
# 统一输出目录。Visual Studio Release 实际输出到 out/bin/Release;Linux 单独输出到 out/linux/bin。
|
||||
if(UNIX AND NOT APPLE)
|
||||
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out/linux)
|
||||
else()
|
||||
|
||||
+85
-16
@@ -13,6 +13,7 @@
|
||||
#include <QMessageBox>
|
||||
#include <QSaveFile>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QDebug>
|
||||
#include <string>
|
||||
@@ -46,6 +47,8 @@ const QString kRegistryImportedAtKey = QStringLiteral("imported_at_utc");
|
||||
const QString kEmbeddedServerConfigPath = QStringLiteral(":/simcae/server_config.json");
|
||||
const QString kApiBaseUrlKey = QStringLiteral("api_base_url");
|
||||
|
||||
// 需要提权写入时,子进程参数统一用 Base64Url 编码。
|
||||
// 这样可以避免 Windows 路径、中文、空格或换行在 ShellExecute 参数传递中被截断或误解析。
|
||||
QString encodeArgument(const QString& value)
|
||||
{
|
||||
return QString::fromLatin1(value.toUtf8().toBase64(
|
||||
@@ -173,9 +176,31 @@ bool writeConfigValueToFile(const QString& configPath, const QString& key,
|
||||
|
||||
bool isRegistryManagedConfigKey(const QString& key)
|
||||
{
|
||||
// 服务端地址是编译期 qrc 配置,不进入注册表。
|
||||
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
|
||||
return key != kApiBaseUrlKey;
|
||||
}
|
||||
|
||||
bool isPathInsideDirectory(const QString& path, const QString& directory)
|
||||
{
|
||||
if (path.isEmpty() || directory.isEmpty())
|
||||
return false;
|
||||
|
||||
QString normalizedPath = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
||||
QString normalizedDirectory = QDir::cleanPath(QFileInfo(directory).absoluteFilePath());
|
||||
#ifdef Q_OS_WIN
|
||||
normalizedPath = normalizedPath.toLower();
|
||||
normalizedDirectory = normalizedDirectory.toLower();
|
||||
#endif
|
||||
return normalizedPath == normalizedDirectory
|
||||
|| normalizedPath.startsWith(normalizedDirectory + QDir::separator());
|
||||
}
|
||||
|
||||
bool isUserDataPath(const QString& path)
|
||||
{
|
||||
return isPathInsideDirectory(path, ConfigHelper::instance().dataRoot());
|
||||
}
|
||||
|
||||
QJsonObject registryManagedConfigObject(const QJsonObject& source)
|
||||
{
|
||||
QJsonObject result;
|
||||
@@ -198,6 +223,8 @@ QString windowsErrorMessage(DWORD errorCode)
|
||||
bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetPath,
|
||||
const QString& originalError, QString* errorMessage)
|
||||
{
|
||||
// 安装到 C:\Program Files 等目录时,普通用户不能直接修改配置或运行态文件。
|
||||
// 这里不让主进程一直以管理员运行,而是在确实需要写入时临时拉起自身完成单次写入。
|
||||
const QMessageBox::StandardButton choice = QMessageBox::question(
|
||||
nullptr,
|
||||
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
|
||||
@@ -397,6 +424,12 @@ bool ConfigHelper::writeFileWithElevationIfNeeded(const QString& path, const QBy
|
||||
}
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
if (isUserDataPath(path))
|
||||
{
|
||||
if (errorMessage)
|
||||
*errorMessage = localError;
|
||||
return false;
|
||||
}
|
||||
if (data.size() > 24 * 1024)
|
||||
{
|
||||
if (errorMessage)
|
||||
@@ -423,6 +456,12 @@ bool ConfigHelper::removeFileWithElevationIfNeeded(const QString& path, QString*
|
||||
}
|
||||
|
||||
#ifdef Q_OS_WIN
|
||||
if (isUserDataPath(path))
|
||||
{
|
||||
if (errorMessage)
|
||||
*errorMessage = localError;
|
||||
return false;
|
||||
}
|
||||
return removeFileWithElevation(path, localError, errorMessage);
|
||||
#else
|
||||
if (errorMessage)
|
||||
@@ -458,15 +497,44 @@ QString ConfigHelper::installRoot() const
|
||||
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::runtimeRoot() const
|
||||
{
|
||||
return QDir::cleanPath(QApplication::applicationDirPath());
|
||||
}
|
||||
|
||||
QString ConfigHelper::dataRoot() const
|
||||
{
|
||||
QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
|
||||
if (base.isEmpty())
|
||||
base = QDir::homePath();
|
||||
return QDir::cleanPath(QDir(base).filePath(
|
||||
QStringLiteral("Marsco/UpdateClientSDK/installations/%1").arg(m_registryInstallId)));
|
||||
}
|
||||
|
||||
QString ConfigHelper::dataConfigDir() const
|
||||
{
|
||||
return QDir(dataRoot()).filePath(QStringLiteral("config"));
|
||||
}
|
||||
|
||||
QString ConfigHelper::clientIdentityPath() const
|
||||
{
|
||||
return QDir(dataConfigDir()).filePath(QStringLiteral("client_identity.dat"));
|
||||
}
|
||||
|
||||
QString ConfigHelper::policyPath() const
|
||||
{
|
||||
return QDir(dataConfigDir()).filePath(QStringLiteral("version_policy.dat"));
|
||||
}
|
||||
|
||||
QString ConfigHelper::localStatePath() const
|
||||
{
|
||||
return QDir(dataConfigDir()).filePath(QStringLiteral("local_state.json"));
|
||||
}
|
||||
|
||||
QString ConfigHelper::updateRoot() const
|
||||
{
|
||||
return QDir(dataRoot()).filePath("update");
|
||||
}
|
||||
|
||||
QString ConfigHelper::runtimeRelativePath() const
|
||||
{
|
||||
@@ -572,16 +640,15 @@ bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
|
||||
|
||||
if (configChangedAfterPreviousImport)
|
||||
{
|
||||
const QString configDir = QFileInfo(m_configPath).absolutePath();
|
||||
const QStringList staleFiles{
|
||||
QDir(configDir).filePath(QStringLiteral("client_identity.dat")),
|
||||
QDir(configDir).filePath(QStringLiteral("version_policy.dat")),
|
||||
QDir(configDir).filePath(QStringLiteral("local_state.json"))
|
||||
clientIdentityPath(),
|
||||
policyPath(),
|
||||
localStatePath()
|
||||
};
|
||||
for (const QString& staleFile : staleFiles)
|
||||
{
|
||||
QString removeError;
|
||||
if (!ConfigHelper::removeFileWithElevationIfNeeded(staleFile, &removeError))
|
||||
if (!removeFile(staleFile, &removeError))
|
||||
{
|
||||
m_error = QStringLiteral("Cannot remove stale runtime file after config change: %1. %2")
|
||||
.arg(staleFile, removeError);
|
||||
@@ -608,9 +675,11 @@ bool ConfigHelper::sanitizeConfigFileAfterImport(QSettings& settings)
|
||||
QCryptographicHash::hash(normalizedEmptyConfig, QCryptographicHash::Sha256).toHex());
|
||||
|
||||
QString writeError;
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_configPath, emptyFileBytes, &writeError))
|
||||
if (!writeBytesToFile(m_configPath, emptyFileBytes, &writeError))
|
||||
{
|
||||
m_error = QStringLiteral("Cannot clear app_config.json after registry import: %1").arg(writeError);
|
||||
// 清空 app_config.json 只是为了减少明文配置暴露,不是启动必需步骤。
|
||||
// 如果安装目录或文件只读,不再为了清空源配置弹 UAC;运行配置已经写入 HKCU 注册表。
|
||||
m_error = QStringLiteral("Cannot clear app_config.json after registry import without elevation: %1").arg(writeError);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@ 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 installRoot() const;
|
||||
QString runtimeRoot() const;
|
||||
QString dataRoot() const;
|
||||
QString dataConfigDir() const;
|
||||
QString clientIdentityPath() const;
|
||||
QString policyPath() const;
|
||||
QString localStatePath() const;
|
||||
QString updateRoot() const;
|
||||
QString runtimeRelativePath() const;
|
||||
QString lastError() const;
|
||||
|
||||
|
||||
+296
-43
@@ -1,48 +1,301 @@
|
||||
#include "DeviceIdentityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include "DeviceIdentityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSysInfo>
|
||||
#include <QUuid>
|
||||
#include <QTimer>
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
|
||||
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
|
||||
QString DeviceIdentityHelper::errorString() const{return m_error;}
|
||||
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
|
||||
#else
|
||||
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
|
||||
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
|
||||
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
|
||||
#endif
|
||||
}
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
|
||||
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
|
||||
}
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
|
||||
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
|
||||
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 config/server_config.json before building the client, 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(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");QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);QString writeError;if(!ConfigHelper::writeFileWithElevationIfNeeded(path,bytes,&writeError)){m_error=QString("cannot save device credential to %1: %2").arg(path,writeError);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;
|
||||
#include <QUuid>
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
QString manifestPublicKeyPath(const QString &installDir)
|
||||
{
|
||||
return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem"));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir)
|
||||
: m_installDir(installDir)
|
||||
{
|
||||
}
|
||||
|
||||
QString DeviceIdentityHelper::deviceId() const
|
||||
{
|
||||
return m_deviceId;
|
||||
}
|
||||
|
||||
QString DeviceIdentityHelper::errorString() const
|
||||
{
|
||||
return m_error;
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64)
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload);
|
||||
Q_UNUSED(signatureBase64);
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"OpenSSL is unavailable, so device credential signature cannot be verified.");
|
||||
return false;
|
||||
#else
|
||||
const QString keyPath = manifestPublicKeyPath(m_installDir);
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||
if (bio) {
|
||||
BIO_free(bio);
|
||||
}
|
||||
if (!publicKey) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
|
||||
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
const bool ok = ctx
|
||||
&& EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1
|
||||
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
|
||||
&& EVP_DigestVerifyFinal(
|
||||
ctx,
|
||||
reinterpret_cast<const unsigned char *>(signature.constData()),
|
||||
signature.size()) == 1;
|
||||
|
||||
if (ctx) {
|
||||
EVP_MD_CTX_free(ctx);
|
||||
}
|
||||
EVP_PKEY_free(publicKey);
|
||||
|
||||
if (!ok) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"Device credential signature is invalid. The local identity file may not match this server.");
|
||||
}
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel)
|
||||
{
|
||||
// client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。
|
||||
// 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。
|
||||
QString credentialPath = ConfigHelper::instance().clientIdentityPath();
|
||||
if (!QFile::exists(credentialPath)) {
|
||||
credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat"));
|
||||
}
|
||||
|
||||
QFile credentialFile(credentialPath);
|
||||
if (!credentialFile.open(QIODevice::ReadOnly)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1")
|
||||
.arg(credentialPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject wrapper = wrapperDoc.object();
|
||||
const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8();
|
||||
const QString signature = wrapper.value(QStringLiteral("signature")).toString();
|
||||
if (identityText.isEmpty() || !verifySignature(identityText, signature)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText);
|
||||
const QJsonObject identity = identityDoc.object();
|
||||
const QDateTime expiry = QDateTime::fromString(
|
||||
identity.value(QStringLiteral("valid_until")).toString(),
|
||||
Qt::ISODate);
|
||||
|
||||
const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId
|
||||
&& identity.value(QStringLiteral("channel")).toString() == expectedChannel
|
||||
&& !identity.value(QStringLiteral("license_id")).toString().isEmpty()
|
||||
&& !identity.value(QStringLiteral("installation_id")).toString().isEmpty()
|
||||
&& !identity.value(QStringLiteral("device_id")).toString().isEmpty();
|
||||
if (!identityMatches) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"Device credential does not match this application, channel, license, installation or device.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"License has expired. Please ask the administrator to issue a new License.");
|
||||
return false;
|
||||
}
|
||||
|
||||
m_deviceId = identity.value(QStringLiteral("device_id")).toString();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel)
|
||||
{
|
||||
m_error.clear();
|
||||
return loadAndVerify(appId, channel);
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::ensureIssued(
|
||||
const QString &apiBaseUrl,
|
||||
const QString &clientToken,
|
||||
const QString &appId,
|
||||
const QString &channel,
|
||||
const QString &licenseKey)
|
||||
{
|
||||
// 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。
|
||||
// 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。
|
||||
m_error.clear();
|
||||
if (loadAndVerify(appId, channel)) {
|
||||
ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const QString trimmedBaseUrl = apiBaseUrl.trimmed();
|
||||
if (appId.trimmed().isEmpty()) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json.");
|
||||
return false;
|
||||
}
|
||||
if (channel.trimmed().isEmpty()) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json.");
|
||||
return false;
|
||||
}
|
||||
if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000");
|
||||
return false;
|
||||
}
|
||||
if (clientToken.trimmed().isEmpty()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"client_token is empty. Copy the client_token generated by the admin page into app_config.json.");
|
||||
return false;
|
||||
}
|
||||
if (licenseKey.trimmed().isEmpty()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"License is empty. Create or select a License in the admin page, then copy the generated client configuration.");
|
||||
return false;
|
||||
}
|
||||
|
||||
ConfigHelper &config = ConfigHelper::instance();
|
||||
QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
|
||||
if (installationId.isEmpty()) {
|
||||
installationId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8();
|
||||
const QString machineHash = QString::fromLatin1(
|
||||
QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex());
|
||||
const QJsonObject body{
|
||||
{QStringLiteral("app_id"), appId},
|
||||
{QStringLiteral("channel"), channel},
|
||||
{QStringLiteral("license_key"), licenseKey},
|
||||
{QStringLiteral("installation_id"), installationId},
|
||||
{QStringLiteral("machine_hash"), machineHash},
|
||||
};
|
||||
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||
|
||||
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
|
||||
QEventLoop loop;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
|
||||
bool timeoutOk = false;
|
||||
int timeoutMs = ConfigHelper::instance()
|
||||
.getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms"))
|
||||
.toInt(&timeoutOk);
|
||||
if (!timeoutOk || timeoutMs < 1000) {
|
||||
timeoutMs = 5000;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray raw = reply->readAll();
|
||||
reply->deleteLater();
|
||||
if (status != 200) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device identity request failed (HTTP %1): %2")
|
||||
.arg(status)
|
||||
.arg(QString::fromUtf8(raw));
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonDocument responseDoc = QJsonDocument::fromJson(raw);
|
||||
const QJsonObject response = responseDoc.object();
|
||||
if (response.value(QStringLiteral("identity_text")).toString().isEmpty()
|
||||
|| response.value(QStringLiteral("signature")).toString().isEmpty()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"DeviceIdentityHelper",
|
||||
"Server returned an invalid device identity response.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject wrapper{
|
||||
{QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))},
|
||||
{QStringLiteral("signature"), response.value(QStringLiteral("signature"))},
|
||||
};
|
||||
const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
const QString credentialPath = config.clientIdentityPath();
|
||||
|
||||
QString writeError;
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2")
|
||||
.arg(credentialPath, writeError);
|
||||
return false;
|
||||
}
|
||||
if (!loadAndVerify(appId, channel)) {
|
||||
return false;
|
||||
}
|
||||
if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) {
|
||||
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
#pragma once
|
||||
#include <QString>
|
||||
class DeviceIdentityHelper {
|
||||
public:
|
||||
explicit DeviceIdentityHelper(const QString& installDir);
|
||||
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
|
||||
const QString& channel, const QString& licenseKey);
|
||||
bool verifyLocal(const QString& appId, const QString& channel);
|
||||
QString deviceId() const;
|
||||
QString errorString() const;
|
||||
private:
|
||||
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
|
||||
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
|
||||
QString m_installDir, m_deviceId, m_error;
|
||||
};
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
class DeviceIdentityHelper {
|
||||
public:
|
||||
explicit DeviceIdentityHelper(const QString &installDir);
|
||||
|
||||
bool ensureIssued(
|
||||
const QString &apiBaseUrl,
|
||||
const QString &clientToken,
|
||||
const QString &appId,
|
||||
const QString &channel,
|
||||
const QString &licenseKey);
|
||||
bool verifyLocal(const QString &appId, const QString &channel);
|
||||
|
||||
QString deviceId() const;
|
||||
QString errorString() const;
|
||||
|
||||
private:
|
||||
bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel);
|
||||
bool verifySignature(const QByteArray &payload, const QString &signatureBase64);
|
||||
|
||||
QString m_installDir;
|
||||
QString m_deviceId;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
@@ -17,7 +17,10 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
// Add auth token header
|
||||
QString token = ConfigHelper::instance().getValue("Server", "client_token");
|
||||
req.setRawHeader("X-Client-Token", token.toUtf8());
|
||||
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
|
||||
QString identityPath = ConfigHelper::instance().clientIdentityPath();
|
||||
if (!QFile::exists(identityPath))
|
||||
identityPath = QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat");
|
||||
QFile identity(identityPath);
|
||||
if (identity.open(QIODevice::ReadOnly))
|
||||
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
|
||||
|
||||
@@ -64,4 +67,4 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
|
||||
reply->deleteLater();
|
||||
manager->deleteLater();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -26,8 +26,10 @@ bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||
&& !clean.startsWith("../") && !clean.contains(":");
|
||||
}
|
||||
|
||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||
{
|
||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||
{
|
||||
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
|
||||
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
|
||||
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
QSet<QString> protectedPaths{
|
||||
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
@@ -81,11 +83,13 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
||||
#endif
|
||||
}
|
||||
|
||||
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
|
||||
const QString& version)
|
||||
{
|
||||
m_error.clear();
|
||||
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
|
||||
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
|
||||
const QString& version)
|
||||
{
|
||||
m_error.clear();
|
||||
// Manifest cache 来自服务端发布版本时生成的签名清单。
|
||||
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
|
||||
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");
|
||||
@@ -132,9 +136,11 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
|
||||
}
|
||||
|
||||
QDir root(m_installDir);
|
||||
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
QDir root(m_installDir);
|
||||
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
|
||||
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
|
||||
// 这能降低被人偷偷塞插件或可执行文件的风险。
|
||||
while (it.hasNext()) {
|
||||
const QString fullPath = it.next();
|
||||
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
||||
const QString folded = relative.toCaseFolded();
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
#include "LocalStateHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
|
||||
LocalStateHelper::LocalStateHelper(const QString& baseDir)
|
||||
: m_baseDir(baseDir)
|
||||
, m_filePath(baseDir + "/config/local_state.json")
|
||||
{
|
||||
}
|
||||
|
||||
bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
{
|
||||
m_filePath = m_baseDir + "/" + relativePath;
|
||||
LocalStateHelper::LocalStateHelper(const QString& baseDir)
|
||||
: m_baseDir(baseDir)
|
||||
, m_filePath(ConfigHelper::instance().localStatePath())
|
||||
{
|
||||
}
|
||||
|
||||
bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
{
|
||||
const QString defaultState = QStringLiteral("config/local_state.json");
|
||||
if (relativePath == defaultState)
|
||||
m_filePath = ConfigHelper::instance().localStatePath();
|
||||
else if (QDir::isAbsolutePath(relativePath))
|
||||
m_filePath = relativePath;
|
||||
else
|
||||
m_filePath = m_baseDir + "/" + relativePath;
|
||||
QFile file(m_filePath);
|
||||
if (!file.exists())
|
||||
{
|
||||
|
||||
+22
-7
@@ -2,6 +2,7 @@
|
||||
#include "ConfigHelper.h"
|
||||
#include <QApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
@@ -11,12 +12,25 @@
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
|
||||
|
||||
bool PolicyHelper::loadPolicy(const QString& relativePath)
|
||||
{
|
||||
QFile file(m_baseDir + "/" + relativePath);
|
||||
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
|
||||
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
|
||||
|
||||
static QString resolvePolicyPath(const QString& baseDir, const QString& relativePath, bool forWrite)
|
||||
{
|
||||
const QString defaultPolicy = QStringLiteral("config/version_policy.dat");
|
||||
if (relativePath == defaultPolicy) {
|
||||
const QString runtimePolicy = ConfigHelper::instance().policyPath();
|
||||
if (forWrite || QFile::exists(runtimePolicy))
|
||||
return runtimePolicy;
|
||||
}
|
||||
if (QDir::isAbsolutePath(relativePath))
|
||||
return relativePath;
|
||||
return baseDir + "/" + relativePath;
|
||||
}
|
||||
|
||||
bool PolicyHelper::loadPolicy(const QString& relativePath)
|
||||
{
|
||||
QFile file(resolvePolicyPath(m_baseDir, relativePath, false));
|
||||
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; }
|
||||
QJsonParseError error;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
@@ -35,7 +49,8 @@ bool PolicyHelper::savePolicy(const QString& relativePath) const
|
||||
{
|
||||
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
|
||||
QString writeError;
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_baseDir + "/" + relativePath, bytes, &writeError)) {
|
||||
const QString path = resolvePolicyPath(m_baseDir, relativePath, true);
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) {
|
||||
m_error = "Cannot save policy file: " + writeError;
|
||||
return false;
|
||||
}
|
||||
|
||||
+16
-12
@@ -10,17 +10,19 @@
|
||||
#include <QStandardPaths>
|
||||
#include <QUuid>
|
||||
|
||||
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
{
|
||||
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
{
|
||||
return QMessageAuthenticationCode::hash(
|
||||
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
const QString& version, const QString& secret,
|
||||
QString* ticketPath, QString* errorMessage)
|
||||
{
|
||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
const QString& version, const QString& secret,
|
||||
QString* ticketPath, QString* errorMessage)
|
||||
{
|
||||
// Launcher 启动业务主程序前生成一次性 ticket。
|
||||
// ticket 只保存在临时目录、有效期 60 秒,并用 launch_token 做 HMAC,防止用户绕过 Launcher 直接启动主程序。
|
||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
||||
return false;
|
||||
}
|
||||
@@ -47,11 +49,13 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage)
|
||||
{
|
||||
const QString consumingPath = ticketPath + ".consuming."
|
||||
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage)
|
||||
{
|
||||
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
|
||||
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
|
||||
const QString consumingPath = ticketPath + ".consuming."
|
||||
+ QString::number(QCoreApplication::applicationPid());
|
||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||
if (errorMessage) *errorMessage = "ticket missing or already consumed";
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
```powershell
|
||||
cd update-client
|
||||
.\scripts\package-sdk.ps1 -SourceDir .\out\bin -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
|
||||
.\scripts\package-sdk.ps1 -SourceDir .\out\bin\Release -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0
|
||||
```
|
||||
|
||||
如果你要重新打 Linux SDK 包:
|
||||
@@ -49,7 +49,8 @@ config/server_config.json 是编译期服务端地址配置源文件。它通过
|
||||
非空 app_config.json 成功导入注册表后会自动清空为 {},文件保留不删除,方便下次直接粘贴管理后台生成的新配置。
|
||||
Windows 注册表位置:HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config。
|
||||
Linux 配置位置由 Qt QSettings 决定,通常在当前用户 home 目录的 .config/Marsco/UpdateClientSDK.conf 一类路径下。
|
||||
如果检测到 app_config.json 发生变化,SDK 会删除 config/client_identity.dat、config/version_policy.dat 和 config/local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;目录不可写时会弹出管理员权限确认框。
|
||||
Windows 运行态数据目录类似:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\。
|
||||
如果检测到 app_config.json 发生变化,SDK 会删除当前用户数据目录里的 client_identity.dat、version_policy.dat 和 local_state.json,避免继续使用旧授权身份、旧策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
|
||||
正常启动成功后不要删除这些状态文件,它们用于本地身份、离线策略和安全状态。
|
||||
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
|
||||
|
||||
@@ -68,7 +69,10 @@ Windows 完整格式参考 update-client/config/app_config.example.json;Linux
|
||||
Windows 发布打包:
|
||||
|
||||
1. 使用 Release 配置编译全部客户端程序。
|
||||
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
|
||||
2. 先完成当前版本在线校验。签名 Manifest 缓存现在默认保存在当前用户数据目录:
|
||||
Windows:%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache
|
||||
Linux:$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache,未设置 XDG_DATA_HOME 时通常是 ~/.local/share。
|
||||
打包脚本会优先从用户数据目录读取,也兼容旧版 Release 目录里的 update/manifest_cache。
|
||||
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名;确认客户端程序已用正确的 config/server_config.json 编译。
|
||||
4. 在 PowerShell 执行:
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\package-client.ps1 -ConfigFile .\config\app_config.json
|
||||
|
||||
+26
-11
@@ -34,8 +34,9 @@ SDK 核心程序:
|
||||
|
||||
1. 已在 Windows 上用 Release 配置编译完成,输出目录里有 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe`。
|
||||
2. `config/manifest_public_key.pem` 和服务端使用的私钥是一对。
|
||||
3. SDK Word 接入说明已经放在 `update-client` 根目录或 `update-client/Docs` 目录下,文件名包含 `SDK`,例如 `SimCAE自动升级SDK接入说明_v0.1.docx`。打包脚本会把它复制到 SDK 根目录,方便接入方一打开压缩包就能看到。
|
||||
4. 如果业务软件本身已经带 Qt DLL,通常不要把 SDK 的 Qt 运行库打进去,避免 Qt 版本混用。
|
||||
3. `update-client/Docs` 里的 Markdown 文档会随 SDK 一起打包,是默认接入说明来源。
|
||||
4. Word 接入说明是可选增强。如果本地有文件名包含 `SDK` 的 `.docx`,打包脚本会额外复制到 SDK 根目录;如果没有,也不会影响 SDK 打包。
|
||||
5. 如果业务软件本身已经带 Qt DLL,通常不要把 SDK 的 Qt 运行库打进去,避免 Qt 版本混用。
|
||||
|
||||
在 Windows PowerShell 中执行:
|
||||
|
||||
@@ -43,7 +44,7 @@ SDK 核心程序:
|
||||
cd C:\Users\admin\Desktop\update-client
|
||||
|
||||
.\scripts\package-sdk.ps1 `
|
||||
-SourceDir .\out\bin `
|
||||
-SourceDir .\out\bin\Release `
|
||||
-OutputDir .\dist\UpdateClientSDK `
|
||||
-ZipFile .\dist\UpdateClientSDK.zip `
|
||||
-SdkVersion 0.1.0
|
||||
@@ -54,8 +55,11 @@ cd C:\Users\admin\Desktop\update-client
|
||||
```text
|
||||
dist/
|
||||
UpdateClientSDK/
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||
sdk_manifest.json
|
||||
Docs/
|
||||
00-先读我-客户端文档入口.txt
|
||||
01-客户端接入打包部署指南.md
|
||||
02-编译环境和第三方依赖说明.md
|
||||
bin/
|
||||
Launcher.exe
|
||||
Updater.exe
|
||||
@@ -68,6 +72,7 @@ dist/
|
||||
install-sdk.ps1
|
||||
package-client.ps1
|
||||
package-sdk.ps1
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现
|
||||
UpdateClientSDK.zip
|
||||
```
|
||||
|
||||
@@ -98,8 +103,11 @@ Linux SDK 包里核心程序名不带 `.exe`:
|
||||
```text
|
||||
dist/
|
||||
UpdateClientSDK-linux/
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx
|
||||
sdk_manifest.json
|
||||
Docs/
|
||||
00-先读我-客户端文档入口.txt
|
||||
01-客户端接入打包部署指南.md
|
||||
02-编译环境和第三方依赖说明.md
|
||||
bin/
|
||||
Launcher
|
||||
Updater
|
||||
@@ -115,6 +123,7 @@ dist/
|
||||
scripts/
|
||||
package-sdk.sh
|
||||
package-client.sh
|
||||
SimCAE自动升级SDK接入说明_v0.1.docx # 可选:只有本地存在 Word 说明时才会出现
|
||||
UpdateClientSDK-linux.tar.gz
|
||||
```
|
||||
|
||||
@@ -196,13 +205,14 @@ D:\SimCAE_SDK\scripts\install-sdk.ps1 `
|
||||
|
||||
- `app_config.json` 是部署配置源文件,适合交付、复制、人工修改。
|
||||
- `api_base_url` 不再属于 `app_config.json` 字段。它只存在于 `config/server_config.json`,并通过 qrc 编进程序。
|
||||
- 交付给你的 SDK 运行包不会包含 `server_config.json` 和 `server_config.qrc`。它们是编译材料,不是运行配置;修改 SDK 包里的文件不会改变已经编译好的 `Launcher.exe`。
|
||||
- Launcher / Updater / MainApp 启动时会计算 `app_config.json` 解析后的 JSON 内容 SHA256;如果 JSON 内容和上次导入时不同,就把文件里的配置重新写入当前 Windows 用户的注册表。
|
||||
- 后续运行时优先从注册表读取配置,不再每次直接读 JSON。
|
||||
- 运行过程中产生的动态值,例如首次输入的 `license_key`、服务端返回的 `device_id`、升级后的 `current_version`,会写入注册表。
|
||||
- 为减少明文暴露,SDK 成功把非空 `app_config.json` 导入注册表后,会把 `app_config.json` 内容自动清空为 `{}`,但不会删除这个文件。以后你从管理后台复制新的客户端配置时,直接覆盖这个文件即可。
|
||||
- SDK 会同步更新注册表里的 `source_sha256`,所以 `{}` 不会在下一次启动时反向覆盖注册表配置。
|
||||
- 如果你手动修改了 `app_config.json`,下一次启动会重新导入并覆盖注册表里的同名字段。
|
||||
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除 `config/client_identity.dat`、`config/version_policy.dat` 和 `config/local_state.json`,避免继续使用旧 License、旧设备身份、旧版本策略或旧防回滚状态;如果安装目录不可写,会弹出管理员权限确认框。
|
||||
- 如果检测到 `app_config.json` 确实发生变化,SDK 会同时删除当前用户数据目录里的 `client_identity.dat`、`version_policy.dat` 和 `local_state.json`,避免继续使用旧 License、旧设备身份、旧版本策略或旧防回滚状态;这些运行态文件不再默认写入安装目录。
|
||||
- 正常启动成功后,不要删除 `client_identity.dat`、`version_policy.dat`、`local_state.json`。它们分别用于本地设备身份、离线策略和防回滚/防时间倒退,删除后会影响离线启动或导致重新授权。
|
||||
- 注册表按安装目录隔离;同一台电脑上多个安装目录不会互相覆盖配置。
|
||||
- 注册表位置为 `HKEY_CURRENT_USER\Software\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\config`。
|
||||
@@ -343,9 +353,9 @@ D:\SimCAE_Release\
|
||||
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
|
||||
```
|
||||
|
||||
如果安装在 `C:\Program Files\...` 这类默认不可写目录,普通配置值会写入当前用户注册表,不需要修改 `app_config.json`。但 `client_identity.dat`、`local_state.json`、`version_policy.dat` 等本地状态文件仍位于安装目录下;当这些文件需要写入且目录不可写时,SDK 会弹出 Windows 管理员权限确认框,用户点击“是”后会继续保存。
|
||||
如果安装在 `C:\Program Files\...`、`D:\...` 或其他普通用户不一定可写的目录,SDK 的普通配置值会写入当前用户注册表,不需要修改 `app_config.json`。`client_identity.dat`、`local_state.json`、`version_policy.dat`、更新缓存和 Manifest 缓存也会写入当前用户数据目录,不再默认写到安装目录。
|
||||
|
||||
注意:这次提权主要覆盖小型配置/状态文件的写入和删除。更新缓存、离线包暂存、升级替换 EXE/DLL 等大文件操作仍建议放在可写目录;如果最终产品必须完整安装到 `C:\Program Files\SimCAE\bin` 并在普通用户下自动升级,后续建议把运行时状态迁移到 `ProgramData` / `AppData`,或让 Updater/Bootstrap 在替换安装目录文件时走管理员权限。
|
||||
Windows 用户数据目录类似:`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\`。因此日常启动、首次授权、保存设备身份、保存策略、防回滚状态和下载缓存不应再触发管理员权限确认框。只有真正要替换安装目录里的 EXE/DLL 等程序文件时,才需要保证安装目录可写,或让安装器/Updater 具备相应权限。
|
||||
|
||||
## 八、维护者:生成某个产品的最终客户端包
|
||||
|
||||
@@ -357,7 +367,7 @@ SDK 是给接入方开发使用的。最终给用户安装或分发时,可以
|
||||
cd C:\Users\admin\Desktop\update-client
|
||||
|
||||
.\scripts\package-client.ps1 `
|
||||
-SourceDir .\out\bin `
|
||||
-SourceDir .\out\bin\Release `
|
||||
-ConfigFile .\config\app_config.json `
|
||||
-OutputDir .\dist\UpdateClient `
|
||||
-ZipFile .\dist\UpdateClient.zip
|
||||
@@ -368,7 +378,9 @@ cd C:\Users\admin\Desktop\update-client
|
||||
- 配置文件必填字段是否完整。
|
||||
- 主程序、Launcher、Updater、Bootstrap 是否存在。
|
||||
- 是否混入 Debug DLL、PDB、ILK。
|
||||
- 当前版本是否已有签名 Manifest 缓存。
|
||||
- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取:
|
||||
`%LOCALAPPDATA%\Marsco\UpdateClientSDK\installations\<安装目录SHA256>\update\manifest_cache`。
|
||||
同时兼容旧版 Release 目录里的 `update\manifest_cache`。
|
||||
- 是否存在重复主程序。
|
||||
|
||||
生成结果:
|
||||
@@ -396,7 +408,10 @@ Linux 打包脚本会检查:
|
||||
- `app_config.json` 必填字段是否完整。
|
||||
- 主程序、Launcher、Updater、Bootstrap 是否存在。
|
||||
- 是否混入 Debug 产物。
|
||||
- 当前版本是否已有签名 Manifest 缓存。
|
||||
- 当前版本是否已有签名 Manifest 缓存。脚本会优先从当前用户数据目录读取:
|
||||
`$XDG_DATA_HOME/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`,
|
||||
未设置 `XDG_DATA_HOME` 时通常是 `~/.local/share/Marsco/UpdateClientSDK/installations/<安装目录SHA256>/update/manifest_cache`。
|
||||
同时兼容旧版 Release 目录里的 `update/manifest_cache`。
|
||||
- 是否存在重复主程序。
|
||||
|
||||
Linux 下如果程序安装在 `/opt`、`/usr/local` 等普通用户不可写目录,升级器无法像 Windows UAC 那样自动提权修改安装目录。正式部署前建议二选一:
|
||||
|
||||
+10
-6
@@ -39,7 +39,7 @@ int main(int argc, char* argv[])
|
||||
QApplication::processEvents();
|
||||
|
||||
const QString appDir = QApplication::applicationDirPath();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
const auto isLicenseError = [](const QString& errorText) {
|
||||
const QString text = errorText.toLower();
|
||||
return text.contains("license")
|
||||
@@ -49,7 +49,7 @@ int main(int argc, char* argv[])
|
||||
|| text.contains("bound to another license");
|
||||
};
|
||||
const auto clearDeviceCredential = [&]() {
|
||||
ConfigHelper::removeFileWithElevationIfNeeded(QDir(appDir).filePath("config/client_identity.dat"));
|
||||
ConfigHelper::removeFileWithElevationIfNeeded(ConfigHelper::instance().clientIdentityPath());
|
||||
config.setValue("Update", "device_id", QString());
|
||||
};
|
||||
QString licenseKey = config.getValue("License", "license_key").trimmed();
|
||||
@@ -98,8 +98,10 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
if (licenseKey.isEmpty()) {
|
||||
while (true) {
|
||||
// License 首次为空、过期或已达设备上限时,在 Launcher 内引导用户重新输入。
|
||||
// 成功后服务端会签发 client_identity.dat,并把真实 device_id 写入运行配置。
|
||||
if (licenseKey.isEmpty()) {
|
||||
licenseKey = promptAndSaveLicense(QString());
|
||||
if (licenseKey.isEmpty()) return 0;
|
||||
}
|
||||
@@ -186,8 +188,10 @@ int main(int argc, char* argv[])
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto startMainApp = [&]() {
|
||||
QString ticketPath;
|
||||
const auto startMainApp = [&]() {
|
||||
// 只允许 Launcher 启动业务主程序:先生成一次性 ticket,再通过 --ticket-file 传给主程序。
|
||||
// 业务主程序必须消费并校验 ticket,避免用户直接双击 SimCAE/MainApp 绕过更新和授权检查。
|
||||
QString ticketPath;
|
||||
QString ticketError;
|
||||
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
|
||||
launchToken, &ticketPath, &ticketError)) {
|
||||
|
||||
@@ -35,9 +35,11 @@ bool UpdateTransaction::copyOverwrite(const QString& source, const QString& dest
|
||||
return QFile::copy(source, destination);
|
||||
}
|
||||
|
||||
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
|
||||
{
|
||||
m_state["transaction_id"] = m_transactionId;
|
||||
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
|
||||
{
|
||||
// upgrade_state.json 是升级事务的“黑匣子”。
|
||||
// 如果替换文件时断电或崩溃,Bootstrap/Updater 会根据这里的状态继续提交或回滚。
|
||||
m_state["transaction_id"] = m_transactionId;
|
||||
m_state["from_version"] = m_fromVersion;
|
||||
m_state["to_version"] = m_toVersion;
|
||||
m_state["status"] = status;
|
||||
@@ -146,9 +148,11 @@ bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
|
||||
return writeState("verified");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::backupCurrentFiles()
|
||||
{
|
||||
if (!writeState("waiting_mainapp_exit")) return false;
|
||||
bool UpdateTransaction::backupCurrentFiles()
|
||||
{
|
||||
// 替换前先备份所有将被修改或删除的文件。
|
||||
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
|
||||
if (!writeState("waiting_mainapp_exit")) return false;
|
||||
QStringList paths = m_changedPaths;
|
||||
paths.append(m_obsoletePaths);
|
||||
for (const QString& path : paths) {
|
||||
@@ -161,9 +165,11 @@ bool UpdateTransaction::backupCurrentFiles()
|
||||
return writeState("backed_up");
|
||||
}
|
||||
|
||||
bool UpdateTransaction::installStagedFiles(QString* failedPath)
|
||||
{
|
||||
if (!writeState("replacing")) return false;
|
||||
bool UpdateTransaction::installStagedFiles(QString* failedPath)
|
||||
{
|
||||
// staging 目录里只放已经下载并校验过 hash 的新文件。
|
||||
// 真正覆盖安装目录时如果任意一个文件失败,就进入 rollback_required。
|
||||
if (!writeState("replacing")) return false;
|
||||
for (const QString& path : m_changedPaths) {
|
||||
const QString source = QDir(m_stagingDir).filePath(path);
|
||||
const QString destination = QDir(m_installDir).filePath(path);
|
||||
@@ -223,10 +229,11 @@ 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, const QString& updateDir,
|
||||
QString* restoredVersion, QString* errorMessage)
|
||||
{
|
||||
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
|
||||
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
|
||||
QString* restoredVersion, QString* errorMessage)
|
||||
{
|
||||
// 启动时恢复未完成事务:如果上次升级停在替换/验证/回滚中间,优先恢复到可启动状态。
|
||||
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))
|
||||
|
||||
@@ -31,9 +31,11 @@ UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
|
||||
}
|
||||
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
QString url = m_serverAddr + "/api/v1/update/manifest";
|
||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||
{
|
||||
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
|
||||
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
|
||||
QString url = m_serverAddr + "/api/v1/update/manifest";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
body["channel"] = channel;
|
||||
@@ -145,9 +147,11 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
||||
#endif
|
||||
}
|
||||
|
||||
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
{
|
||||
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
||||
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
{
|
||||
// 验签用的是客户端随 SDK 分发的公钥。
|
||||
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
|
||||
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
||||
{
|
||||
qDebug() << "No manifest available to verify";
|
||||
return false;
|
||||
@@ -167,9 +171,11 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||
return verifySignature(m_manifestText.toUtf8(), signature, path);
|
||||
}
|
||||
|
||||
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
{
|
||||
if (m_manifest.isEmpty())
|
||||
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||
{
|
||||
// 启动后的完整性检查依赖本地 Manifest 缓存。
|
||||
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
|
||||
if (m_manifest.isEmpty())
|
||||
return false;
|
||||
|
||||
QDir dir(cacheDir);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
#include <QApplication>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"api_base_url": "http://YOUR_SERVER_IP:8000"
|
||||
{
|
||||
"api_base_url": "http://192.168.1.158:8000"
|
||||
}
|
||||
|
||||
Binary file not shown.
+296
-208
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -7,6 +7,8 @@
|
||||
|
||||
1. package-sdk.ps1
|
||||
在 Windows 上生成给其他软件接入用的 UpdateClientSDK 包。
|
||||
注意:SDK 包只面向运行接入,不包含 config/server_config.json 和 config/server_config.qrc。
|
||||
服务端地址必须在打包前写入源码目录 config/server_config.json,并重新编译进 Launcher/Updater。
|
||||
|
||||
2. package-client.ps1
|
||||
在 Windows 上生成某个具体产品的最终客户端发布包。
|
||||
@@ -24,7 +26,7 @@
|
||||
|
||||
```powershell
|
||||
.\scripts\package-sdk.ps1 `
|
||||
-SourceDir .\out\bin `
|
||||
-SourceDir .\out\bin\Release `
|
||||
-OutputDir .\dist\UpdateClientSDK `
|
||||
-ZipFile .\dist\UpdateClientSDK.zip `
|
||||
-SdkVersion 0.1.0
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
```powershell
|
||||
.\scripts\package-client.ps1 `
|
||||
-SourceDir .\out\bin `
|
||||
-SourceDir .\out\bin\Release `
|
||||
-ConfigFile .\config\app_config.json `
|
||||
-OutputDir .\dist\UpdateClient `
|
||||
-ZipFile .\dist\UpdateClient.zip
|
||||
|
||||
+47
-23
@@ -10,7 +10,7 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
|
||||
$SourceDir = Join-Path $RepoRoot "out/bin"
|
||||
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
||||
$OutputDir = Join-Path $RepoRoot "dist/UpdateClient"
|
||||
@@ -40,13 +40,32 @@ $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"
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
function Get-InstallDirectoryId([string]$RuntimeDir) {
|
||||
$normalized = ([IO.Path]::GetFullPath($RuntimeDir) -replace '\\', '/').TrimEnd('/')
|
||||
$sha = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized)
|
||||
$hash = $sha.ComputeHash($bytes)
|
||||
return -join ($hash | ForEach-Object { $_.ToString("x2") })
|
||||
} finally {
|
||||
$sha.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-UserDataManifestCandidate([string]$RuntimeDir, [string]$ManifestName) {
|
||||
$localData = [Environment]::GetFolderPath("LocalApplicationData")
|
||||
if ([string]::IsNullOrWhiteSpace($localData)) { return "" }
|
||||
$installId = Get-InstallDirectoryId $RuntimeDir
|
||||
return Join-Path $localData "Marsco\UpdateClientSDK\installations\$installId\update\manifest_cache\$ManifestName"
|
||||
}
|
||||
|
||||
$requiredFields = @(
|
||||
"app_id", "channel", "current_version",
|
||||
@@ -81,9 +100,9 @@ $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
|
||||
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
|
||||
$_.Extension -in @('.pdb', '.ilk')
|
||||
}
|
||||
if ($debugArtifacts) {
|
||||
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
if ($debugArtifacts) {
|
||||
throw "Source directory contains Debug artifacts. Clean the Release output directory and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
|
||||
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
||||
$mainLeafName = Split-Path $mainExecutable -Leaf
|
||||
@@ -95,18 +114,23 @@ 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"
|
||||
$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."
|
||||
}
|
||||
$manifestName = "manifest_$($settings.current_version).json"
|
||||
$runtimeDirAbsolute = if ([string]::IsNullOrWhiteSpace($runtimeDirRelative)) {
|
||||
$source
|
||||
} else {
|
||||
Join-Path $source ($runtimeDirRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
}
|
||||
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
|
||||
$manifestCandidates = @(
|
||||
(Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
|
||||
(Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
|
||||
(Join-Path $source "update/manifest_cache/$manifestName")
|
||||
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||||
$sourceManifest = $manifestCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if (-not $sourceManifest -or -not (Test-Path $sourceManifest)) {
|
||||
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
|
||||
throw "Missing signed Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging. Searched paths:$([Environment]::NewLine)$searched"
|
||||
}
|
||||
|
||||
if (Test-Path $OutputDir) {
|
||||
Remove-Item $OutputDir -Recurse -Force
|
||||
|
||||
@@ -72,6 +72,27 @@ print(rel.replace(os.sep, "/").strip("/"))
|
||||
PY
|
||||
}
|
||||
|
||||
install_directory_id() {
|
||||
python3 - "$1" <<'PY'
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
|
||||
value = os.path.realpath(sys.argv[1]).replace(os.sep, "/").rstrip("/")
|
||||
print(hashlib.sha256(value.encode("utf-8")).hexdigest())
|
||||
PY
|
||||
}
|
||||
|
||||
user_data_manifest_candidate() {
|
||||
local runtime_dir="$1"
|
||||
local manifest_name="$2"
|
||||
local data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
|
||||
local install_id
|
||||
install_id="$(install_directory_id "$runtime_dir")"
|
||||
printf '%s/Marsco/UpdateClientSDK/installations/%s/update/manifest_cache/%s' \
|
||||
"$data_home" "$install_id" "$manifest_name"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
|
||||
@@ -148,12 +169,27 @@ if [[ -n "$DUPLICATE_MAIN" ]]; then
|
||||
fi
|
||||
|
||||
MANIFEST_NAME="manifest_${CURRENT_VERSION}.json"
|
||||
SOURCE_MANIFEST="$SOURCE_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache/$MANIFEST_NAME")"
|
||||
if [[ ! -f "$SOURCE_MANIFEST" && -f "$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME" ]]; then
|
||||
SOURCE_MANIFEST="$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME"
|
||||
if [[ -z "$RUNTIME_DIR_RELATIVE" ]]; then
|
||||
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR"
|
||||
else
|
||||
RUNTIME_DIR_ABSOLUTE="$SOURCE_DIR/$RUNTIME_DIR_RELATIVE"
|
||||
fi
|
||||
MANIFEST_CANDIDATES=(
|
||||
"$(user_data_manifest_candidate "$RUNTIME_DIR_ABSOLUTE" "$MANIFEST_NAME")"
|
||||
"$SOURCE_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache/$MANIFEST_NAME")"
|
||||
"$SOURCE_DIR/update/manifest_cache/$MANIFEST_NAME"
|
||||
)
|
||||
SOURCE_MANIFEST=""
|
||||
for candidate in "${MANIFEST_CANDIDATES[@]}"; do
|
||||
if [[ -f "$candidate" ]]; then
|
||||
SOURCE_MANIFEST="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$SKIP_MANIFEST_CHECK" -eq 0 && ! -f "$SOURCE_MANIFEST" ]]; then
|
||||
echo "Missing signed Manifest cache for current version: $SOURCE_MANIFEST. Complete online update/verification for this version before packaging." >&2
|
||||
echo "Missing signed Manifest cache for current version: $MANIFEST_NAME." >&2
|
||||
echo "Complete online update/verification for this version before packaging. Searched paths:" >&2
|
||||
printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+15
-8
@@ -12,7 +12,7 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
|
||||
$SourceDir = Join-Path $RepoRoot "out/bin"
|
||||
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
||||
$OutputDir = Join-Path $RepoRoot "dist/UpdateClientSDK"
|
||||
@@ -60,7 +60,8 @@ $binDir = Join-Path $OutputDir "bin"
|
||||
$configDir = Join-Path $OutputDir "config"
|
||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||
$commonDir = Join-Path $OutputDir "Common"
|
||||
New-Item $binDir,$configDir,$scriptsDir,$commonDir -ItemType Directory -Force | Out-Null
|
||||
$docsDir = Join-Path $OutputDir "Docs"
|
||||
New-Item $binDir,$configDir,$scriptsDir,$commonDir,$docsDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
|
||||
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
|
||||
@@ -100,8 +101,6 @@ Get-ChildItem $source -Force | Where-Object {
|
||||
|
||||
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
||||
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
|
||||
Copy-Item (Join-Path $RepoRoot "config/server_config.json") (Join-Path $configDir "server_config.json") -Force
|
||||
Copy-Item (Join-Path $RepoRoot "config/server_config.qrc") (Join-Path $configDir "server_config.qrc") -Force
|
||||
|
||||
$commonSourceDir = Join-Path $RepoRoot "Common"
|
||||
$commonSourceFiles = @(
|
||||
@@ -118,16 +117,22 @@ foreach ($commonFile in $commonSourceFiles) {
|
||||
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
|
||||
}
|
||||
|
||||
$wordGuideSource = @($RepoRoot, (Join-Path $RepoRoot "Docs")) |
|
||||
$docsSourceDir = Join-Path $RepoRoot "Docs"
|
||||
if (Test-Path $docsSourceDir) {
|
||||
Copy-Item (Join-Path $docsSourceDir "*") $docsDir -Recurse -Force
|
||||
}
|
||||
|
||||
$wordGuideSource = @($RepoRoot, $docsSourceDir) |
|
||||
Where-Object { Test-Path $_ } |
|
||||
ForEach-Object { Get-ChildItem $_ -File -Filter "*.docx" } |
|
||||
Where-Object { $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" } |
|
||||
Sort-Object Name |
|
||||
Select-Object -First 1
|
||||
if (-not $wordGuideSource) {
|
||||
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the repository root or Docs directory."
|
||||
if ($wordGuideSource) {
|
||||
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
|
||||
} else {
|
||||
Write-Warning "SDK Word guide is missing. Continue packaging with Markdown documents in Docs/."
|
||||
}
|
||||
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
|
||||
|
||||
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force
|
||||
Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
|
||||
@@ -139,6 +144,8 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in
|
||||
sdk_type = "external-updater-runtime"
|
||||
required_entry = "Launcher.exe"
|
||||
contains_demo_main_app = [bool]$IncludeDemoMainApp
|
||||
docs_entry = "Docs/01-客户端接入打包部署指南.md"
|
||||
word_guide_included = [bool]$wordGuideSource
|
||||
integration_sources = $commonSourceFiles
|
||||
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
|
||||
|
||||
|
||||
+14
-8
@@ -74,7 +74,7 @@ if [[ -n "$DEBUG_ARTIFACT" ]]; then
|
||||
fi
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common"
|
||||
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common" "$OUTPUT_DIR/Docs"
|
||||
|
||||
shopt -s dotglob nullglob
|
||||
for item in "$SOURCE_DIR"/*; do
|
||||
@@ -94,8 +94,6 @@ shopt -u dotglob nullglob
|
||||
|
||||
cp "$EXAMPLE_CONFIG" "$OUTPUT_DIR/config/app_config.json"
|
||||
cp "$PUBLIC_KEY" "$OUTPUT_DIR/config/manifest_public_key.pem"
|
||||
cp "$REPO_ROOT/config/server_config.json" "$OUTPUT_DIR/config/server_config.json"
|
||||
cp "$REPO_ROOT/config/server_config.qrc" "$OUTPUT_DIR/config/server_config.qrc"
|
||||
|
||||
for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.cpp; do
|
||||
common_path="$REPO_ROOT/Common/$common_file"
|
||||
@@ -106,12 +104,18 @@ for common_file in ConfigHelper.h ConfigHelper.cpp TicketHelper.h TicketHelper.c
|
||||
cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
|
||||
done
|
||||
|
||||
WORD_GUIDE="$(find "$REPO_ROOT" "$REPO_ROOT/Docs" -maxdepth 1 -type f -name '*SDK*.docx' ! -name '~$*' 2>/dev/null | sort | sed -n '1p')"
|
||||
if [[ -z "$WORD_GUIDE" ]]; then
|
||||
echo "SDK integration Word guide is missing. Expected a *SDK*.docx file in the repository root or Docs directory." >&2
|
||||
exit 1
|
||||
if [[ -d "$REPO_ROOT/Docs" ]]; then
|
||||
cp -a "$REPO_ROOT/Docs/." "$OUTPUT_DIR/Docs/"
|
||||
fi
|
||||
|
||||
WORD_GUIDE="$(find "$REPO_ROOT" "$REPO_ROOT/Docs" -maxdepth 1 -type f -name '*SDK*.docx' ! -name '~$*' 2>/dev/null | sort | sed -n '1p')"
|
||||
WORD_GUIDE_INCLUDED=false
|
||||
if [[ -n "$WORD_GUIDE" ]]; then
|
||||
cp "$WORD_GUIDE" "$OUTPUT_DIR/$(basename "$WORD_GUIDE")"
|
||||
WORD_GUIDE_INCLUDED=true
|
||||
else
|
||||
echo "Warning: SDK Word guide is missing. Continue packaging with Markdown documents in Docs/." >&2
|
||||
fi
|
||||
cp "$WORD_GUIDE" "$OUTPUT_DIR/$(basename "$WORD_GUIDE")"
|
||||
|
||||
cp "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh"
|
||||
cp "$SCRIPT_DIR/package-client.sh" "$OUTPUT_DIR/scripts/package-client.sh"
|
||||
@@ -130,6 +134,8 @@ cat > "$OUTPUT_DIR/sdk_manifest.json" <<EOF
|
||||
"platform": "linux",
|
||||
"required_entry": "Launcher",
|
||||
"contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false),
|
||||
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
|
||||
"word_guide_included": $WORD_GUIDE_INCLUDED,
|
||||
"integration_sources": [
|
||||
"ConfigHelper.h",
|
||||
"ConfigHelper.cpp",
|
||||
|
||||
Reference in New Issue
Block a user