diff --git a/Bootstrap/main.cpp b/Bootstrap/main.cpp index 97f9a7a..d6b93df 100644 --- a/Bootstrap/main.cpp +++ b/Bootstrap/main.cpp @@ -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)) { diff --git a/CMakeLists.txt b/CMakeLists.txt index 14f847e..05a170f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/Common/ConfigHelper.cpp b/Common/ConfigHelper.cpp index 2e593a0..e58edcb 100644 --- a/Common/ConfigHelper.cpp +++ b/Common/ConfigHelper.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -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; } diff --git a/Common/ConfigHelper.h b/Common/ConfigHelper.h index 0ea4b5b..89660ea 100644 --- a/Common/ConfigHelper.h +++ b/Common/ConfigHelper.h @@ -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; diff --git a/Common/DeviceIdentityHelper.cpp b/Common/DeviceIdentityHelper.cpp index ca417da..d133d48 100644 --- a/Common/DeviceIdentityHelper.cpp +++ b/Common/DeviceIdentityHelper.cpp @@ -1,48 +1,301 @@ -#include "DeviceIdentityHelper.h" -#include "ConfigHelper.h" -#include -#include -#include -#include -#include -#include -#include -#include +#include "DeviceIdentityHelper.h" +#include "ConfigHelper.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include -#include #include -#ifdef HAVE_OPENSSL -#include -#include -#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(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 + +#ifdef HAVE_OPENSSL +#include +#include +#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(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; } diff --git a/Common/DeviceIdentityHelper.h b/Common/DeviceIdentityHelper.h index 33efdcf..c37f1e4 100644 --- a/Common/DeviceIdentityHelper.h +++ b/Common/DeviceIdentityHelper.h @@ -1,15 +1,28 @@ -#pragma once -#include -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 +#include + +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; +}; diff --git a/Common/HttpHelper.cpp b/Common/HttpHelper.cpp index b641313..a58cfa2 100644 --- a/Common/HttpHelper.cpp +++ b/Common/HttpHelper.cpp @@ -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(); -} \ No newline at end of file +} diff --git a/Common/IntegrityHelper.cpp b/Common/IntegrityHelper.cpp index 30f9f9a..7551828 100644 --- a/Common/IntegrityHelper.cpp +++ b/Common/IntegrityHelper.cpp @@ -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 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(); diff --git a/Common/LocalStateHelper.cpp b/Common/LocalStateHelper.cpp index b4eccfc..b8438c0 100644 --- a/Common/LocalStateHelper.cpp +++ b/Common/LocalStateHelper.cpp @@ -1,17 +1,24 @@ #include "LocalStateHelper.h" #include "ConfigHelper.h" +#include #include #include -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()) { diff --git a/Common/PolicyHelper.cpp b/Common/PolicyHelper.cpp index fc64aa4..5e3569b 100644 --- a/Common/PolicyHelper.cpp +++ b/Common/PolicyHelper.cpp @@ -2,6 +2,7 @@ #include "ConfigHelper.h" #include #include +#include #include #include #include @@ -11,12 +12,25 @@ #include #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; } diff --git a/Common/TicketHelper.cpp b/Common/TicketHelper.cpp index 1de50ec..885be5d 100644 --- a/Common/TicketHelper.cpp +++ b/Common/TicketHelper.cpp @@ -10,17 +10,19 @@ #include #include -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"; diff --git a/Docs/00-先读我-客户端文档入口.txt b/Docs/00-先读我-客户端文档入口.txt index 7eb1b17..4664bd4 100644 --- a/Docs/00-先读我-客户端文档入口.txt +++ b/Docs/00-先读我-客户端文档入口.txt @@ -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 diff --git a/Docs/01-客户端接入打包部署指南.md b/Docs/01-客户端接入打包部署指南.md index 32e6ea7..0b8eba6 100644 --- a/Docs/01-客户端接入打包部署指南.md +++ b/Docs/01-客户端接入打包部署指南.md @@ -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 那样自动提权修改安装目录。正式部署前建议二选一: diff --git a/Launcher/main.cpp b/Launcher/main.cpp index 6f23f3c..c9785a9 100644 --- a/Launcher/main.cpp +++ b/Launcher/main.cpp @@ -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)) { diff --git a/Updater/UpdateTransaction.cpp b/Updater/UpdateTransaction.cpp index e26c029..c40b5da 100644 --- a/Updater/UpdateTransaction.cpp +++ b/Updater/UpdateTransaction.cpp @@ -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)) diff --git a/Updater/UpdaterLogic.cpp b/Updater/UpdaterLogic.cpp index d0bac86..eef751b 100644 --- a/Updater/UpdaterLogic.cpp +++ b/Updater/UpdaterLogic.cpp @@ -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); diff --git a/Updater/main.cpp b/Updater/main.cpp index ab6f2af..db5fed8 100644 --- a/Updater/main.cpp +++ b/Updater/main.cpp @@ -1,4 +1,4 @@ -#include +#include #include #include #include diff --git a/config/server_config.json b/config/server_config.json index 1a93e38..dba1221 100644 --- a/config/server_config.json +++ b/config/server_config.json @@ -1,3 +1,3 @@ -{ - "api_base_url": "http://YOUR_SERVER_IP:8000" +{ + "api_base_url": "http://192.168.1.158:8000" } diff --git a/i18n/update-client_zh_CN.qm b/i18n/update-client_zh_CN.qm index cdbfb39..421f287 100644 Binary files a/i18n/update-client_zh_CN.qm and b/i18n/update-client_zh_CN.qm differ diff --git a/i18n/update-client_zh_CN.ts b/i18n/update-client_zh_CN.ts index 078c311..be6421f 100644 --- a/i18n/update-client_zh_CN.ts +++ b/i18n/update-client_zh_CN.ts @@ -1,25 +1,55 @@ + + Bootstrap + + + Update Handoff Failed + 更新交接失败 + + + + Bootstrap arguments are incomplete. + Bootstrap 启动参数不完整。 + + + + The update plan is missing or invalid. + 更新计划文件缺失或格式无效。 + + + + Cannot restart Updater. + 无法重新启动 Updater。 + + + + +Failed file: %1 + +失败文件:%1 + + ConfigHelper - + The user canceled the administrator permission confirmation. 用户取消了管理员权限确认。 - + Windows error %1 Windows 错误 %1 - + Administrator Permission Required 需要管理员权限 - + The current installation directory requires administrator permission to save configuration. Target file: %1 @@ -34,36 +64,124 @@ Click OK, then choose Yes in the Windows permission confirmation dialog. - + The user canceled the administrator permission request. 用户取消了管理员权限请求。 + + DeviceIdentityHelper + + + OpenSSL is unavailable, so device credential signature cannot be verified. + 当前程序未启用 OpenSSL,无法验证设备凭证签名。 + + + + Device public key is missing: %1 + 设备凭证公钥缺失:%1 + + + + Device public key is invalid: %1 + 设备凭证公钥无效:%1 + + + + Device credential signature is invalid. The local identity file may not match this server. + 设备凭证签名无效。本机身份文件可能不是当前服务器签发的。 + + + + Device credential file is not valid JSON: %1 + 设备凭证文件不是合法 JSON:%1 + + + + Device credential does not match this application, channel, license, installation or device. + 设备凭证与当前应用、渠道、License、安装实例或设备不匹配。 + + + + License has expired. Please ask the administrator to issue a new License. + License 已过期。请联系管理员重新签发 License。 + + + + app_id is empty in app_config.json. + app_config.json 中的 app_id 为空。 + + + + channel is empty in app_config.json. + app_config.json 中的 channel 为空。 + + + + Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000 + 服务端地址未配置。请先设置 config/server_config.json,再重新编译 Launcher。例如:http://192.168.229.128:8000 + + + + client_token is empty. Copy the client_token generated by the admin page into app_config.json. + client_token 为空。请把管理后台生成的 client_token 复制到 app_config.json。 + + + + License is empty. Create or select a License in the admin page, then copy the generated client configuration. + License 为空。请先在管理后台创建或选择 License,然后复制生成的客户端配置。 + + + + Cannot save installation id to %1: %2 + 无法保存安装实例 ID 到 %1:%2 + + + + Device identity request failed (HTTP %1): %2 + 设备身份申请失败(HTTP %1):%2 + + + + Server returned an invalid device identity response. + 服务端返回的设备身份响应格式无效。 + + + + Cannot save device credential to %1: %2 + 无法保存设备凭证到 %1:%2 + + + + Cannot save server device id to %1: %2 + 无法保存服务端设备 ID 到 %1:%2 + + Launcher - + Checking for software updates... 正在检查软件更新... - + Marsco Launcher Marsco 软件启动器 - + Please enter the License for %1: 请输入 %1 的授权 License: - - + + the application 软件 - + %1 Please enter a new License for %2: @@ -72,37 +190,37 @@ Please enter a new License for %2: 请重新输入 %2 的授权 License: - + Enter License 输入 License - + License Required 需要 License - + A License provided by the administrator is required for the first launch. 首次启动需要输入管理员提供的 License。 - + License Cannot Be Empty License 不能为空 - + Please paste the License created in the admin page. 请粘贴管理员在后台创建的 License。 - + Failed to Save License 保存 License 失败 - + Cannot write the configuration file: %1 %2 @@ -111,220 +229,220 @@ Please enter a new License for %2: %2 - + Verifying License... 正在验证 License... - + Device Authentication Failed 设备身份验证失败 - + The current License cannot be used: %1 当前 License 无法使用:%1 - + The device or License authorization is invalid. 设备或 License 授权无效。 - + The current authorization was rejected by the server: %1 当前授权被服务端拒绝:%1 - + License Saved License 已保存 - + Please restart Launcher to complete device authorization and update checking. 请重新启动 Launcher 完成设备授权和更新检查。 - + Authorization Rejected 授权被拒绝 - + Select Offline Update Package 选择离线更新包 - + Marsco offline update package (*.upd) Marsco 离线更新包 (*.upd) - - + + Offline Update 离线更新 - + No offline update package was selected. 未选择离线更新包。 - + Verifying local runtime policy... 正在验证本地运行策略... - - - + + + Cannot Start 无法启动 - + The local version policy is invalid: %1 本地版本策略无效:%1 - + The local version policy has expired. Please connect to the network or contact the administrator. 本地版本策略已过期,请连接网络或联系管理员。 - + Current Version Cannot Run 当前版本不可运行 - + The current version %1 has been disabled by the administrator. 当前版本 %1 已被管理员停用。 - + Cannot read the local state: %1 无法读取本地状态:%1 - - + + Security Check Failed 安全检查失败 - + A version policy sequence rollback was detected. Startup has been blocked. 检测到版本策略序列回退,已阻止启动。 - + A possible system time rollback was detected. Startup has been blocked. 检测到系统时间可能被回拨,已阻止启动。 - + Version Rollback 版本回退 - + Update Required 必须更新 - + New Version Available 发现新版本 - + The administrator provided version %1 as the rollback target. Downgrade now? 管理员提供了版本 %1 作为回退目标,是否现在降级? - + Version %1 is available. Update now? 发现新版本 %1,是否现在更新? - + Target version: %1 目标版本:%1 - - + + Startup Failed 启动失败 - - + + Cannot start the main application: %1 无法启动主程序:%1 - + Updater Startup Failed 更新器启动失败 - + Cannot start the updater: %1 无法启动更新器:%1 - + Caching signed version manifest... 正在缓存签名版本清单... - + Manifest Cache Failed 清单缓存失败 - + Cannot cache the signed manifest for the current version: %1 无法缓存当前版本的签名清单:%1 - + Server Unavailable 服务器不可用 - + Cannot connect to the update server. Import an offline update package? 当前无法连接更新服务器。是否导入离线更新包? - + No offline update package was selected, or the updater could not be started. 未选择离线更新包或无法启动更新器。 - + Network Unavailable 网络不可用 - + Cannot connect to the update server, and the current policy does not allow offline startup. 无法连接更新服务器,且当前策略不允许离线启动。 - + The application is up to date. Starting... 当前已是最新版本,正在启动... - + Offline mode is active. Starting... 当前处于离线模式,正在启动... @@ -332,95 +450,95 @@ Target version: %1 Updater - + Invalid Offline Package 离线包无效 - + Updater Argument Error 更新器参数错误 - + The updater is missing online update arguments or an offline update package. 更新器缺少在线更新参数或离线更新包。 - + Offline Package Not Applicable 离线包不适用 - + The update package application or channel does not match the local configuration. 更新包的应用或渠道与本机配置不一致。 - + Offline Update Rejected 离线更新被拒绝 - + The local signed policy has expired, forbids offline updates, or does not allow the target version. 本地签名策略已过期、禁止离线更新或不允许目标版本。 - + Downgrade Forbidden 禁止降级 - + The current signed policy does not allow installing an offline package with a lower version. 当前签名策略不允许安装较低版本的离线包。 - - + + Update Recovery Failed 更新恢复失败 - + An unfinished update was detected, but the old version could not be restored: %1 Do not continue running the software. Please contact the administrator. 检测到上次更新未完成,但无法恢复旧版本:%1 请不要继续运行软件,并联系管理员。 - + The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory. 旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。 - + Preparing update... 正在准备更新... - + Updating to %1 正在更新到 %1 - + Preparing download... 正在准备下载... - + Downloading: %1 正在下载:%1 - + Delegating rollback to Bootstrap... 正在将回滚工作移交给 Bootstrap... - + Cannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator. @@ -429,356 +547,356 @@ Cannot start Bootstrap to perform rollback. Do not continue running the software 无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。 - + Transaction Resume Failed 事务续办失败 - + Cannot read the Bootstrap update transaction: %1 无法读取 Bootstrap 更新事务:%1 - + Update Rolled Back 更新已回滚 - + The new version failed to install or start. The old version has been restored and started automatically. 新版本安装或启动失败,已自动恢复并启动旧版本。 - + The old files were restored, but the old version number could not be written back. Please check configuration directory permissions. 旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。 - + Automatic Rollback Failed 自动回滚失败 - + Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator. Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。 - - + + Update Preparation Failed 更新准备失败 - + Cannot create the update transaction directory or save the transaction state. 无法创建更新事务目录或保存事务状态。 - + Cannot save the offline transaction marker. 无法保存离线事务标记。 - + Fetching and verifying version manifest... 正在获取并验证版本清单... - + Verifying offline update package... 正在验证离线更新包... - - - + + + Manifest Cache Failed 清单缓存失败 - + Cannot read the signed manifest cache after Bootstrap installation. Bootstrap 安装后无法读取签名 Manifest 缓存。 - - + + Security Verification Failed 安全验证失败 - + Cannot reverify the manifest signature after Bootstrap installation. Bootstrap 安装后无法重新验证版本清单签名。 - + The version manifest signature is invalid. The update has stopped. Please contact the administrator. 版本清单签名无效,更新已停止。请联系管理员。 - + Cannot save the new version manifest cache. 无法保存新版本 Manifest 缓存。 - + Cannot save the new version manifest cache. The update has stopped. 无法保存新版本 Manifest 缓存,更新已停止。 - + Fetching secure download URLs... 正在获取安全下载地址... - + Preparing offline package files... 正在准备离线包文件... - + No Files to Update 没有可更新文件 - + The server did not return any version files. The update has stopped. 服务器没有返回任何版本文件,更新已停止。 - + Insufficient Disk Space 磁盘空间不足 - + The update requires at least %1 of free space, but the installation drive currently has only %2. The required space includes downloaded files, old version backups, and a safety margin. 更新至少需要 %1 可用空间,安装盘当前仅剩 %2。 所需空间已包含下载文件、旧版本备份和安全余量。 - + Downloading and verifying %1 version files... 正在下载并校验 %1 个版本文件... - + Extracting and verifying %1 offline files... 正在提取并校验 %1 个离线文件... - + Offline Package Extraction Failed 离线包提取失败 - + Download Failed 下载失败 - + Some files failed to download or failed SHA-256 verification. Please check the network and try again. 部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。 - + Verifying complete version files... 正在校验完整版本文件... - + File Verification Failed 文件校验失败 - + The staged files do not match the version manifest. The update has stopped. 暂存文件与版本清单不一致,更新已停止。 - + Bootstrap Cannot Self-Update Bootstrap 无法自更新 - + This version contains a new %1. Please upgrade this component with the installer, then publish the business version again. 本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。 - + Transaction Recording Failed 事务记录失败 - + Cannot save the list of verified or pending deletion files. The update has stopped. 无法保存已校验或待删除文件列表,更新已停止。 - + Closing the main application... 正在关闭主程序... - + Cannot Close Main Application 无法关闭主程序 - + %1 is still running. Please close it manually and try again. %1 仍在运行,请手动关闭后重试。 - + Backing up %1 files to be changed, including %2 files to be deleted... 正在备份 %1 个待变更文件(其中删除 %2 个)... - + Backup Failed 备份失败 - + Cannot back up current version files. The new version has not been installed. Please check disk space and directory permissions. 无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。 - - - - + + + + Bootstrap Handoff Preparation Failed 接管准备失败 - + Cannot create the Bootstrap file plan. 无法创建 Bootstrap 文件计划。 - + Cannot write the Bootstrap copy plan. 无法写入 Bootstrap 复制计划。 - + Cannot write the Bootstrap deletion plan. 无法写入 Bootstrap 删除计划。 - + Cannot commit the Bootstrap file plan or transaction state. 无法提交 Bootstrap 文件计划或事务状态。 - + Delegating installation to Bootstrap... 正在将安装工作移交给 Bootstrap... - + Bootstrap Startup Failed Bootstrap 启动失败 - + Cannot start the standalone update handoff program: %1 无法启动独立更新接管程序:%1 - + Verifying Bootstrap installation result... 正在校验 Bootstrap 安装结果... - + Installation Verification Failed 安装校验失败 - + New version files failed verification after installation. 新版本文件安装后校验未通过。 - + Obsolete File Cleanup Failed 废弃文件清理失败 - + An obsolete file still exists: %1 废弃文件仍然存在:%1 - + Saving new version state... 正在保存新版本状态... - + State Save Failed 状态保存失败 - + Cannot save the current version number. 无法保存当前版本号。 - + Starting the new version and waiting for health confirmation... 正在启动新版本并等待健康确认... - + Startup Failed 启动失败 - + %1 cannot be started. %1 无法启动。 - + Startup Confirmation Failed 启动确认失败 - + The new version did not complete startup health confirmation within %1 milliseconds. 新版本在 %1 毫秒内没有完成启动健康确认。 - + Committing update transaction... 正在提交更新事务... - + Transaction Commit Failed 事务提交失败 - + The new version has started, but the update transaction could not be committed. 新版本已经启动,但无法提交更新事务。 - + Update Complete 更新完成 - + The software has been successfully updated to %1 and passed the startup health check. 软件已成功更新到 %1,并通过启动健康检查。 @@ -786,119 +904,89 @@ The required space includes downloaded files, old version backups, and a safety UpdaterLogic - + Cannot open the offline update package 无法打开离线更新包 - + The offline package format identifier is invalid 离线包格式标识无效 - + The offline package header is incomplete 离线包头不完整 - + The offline package header length is invalid 离线包头长度无效 - + The offline package header JSON is invalid 离线包头 JSON 无效 - + The offline package RSA signature is invalid 离线包 RSA 签名无效 - + The offline package signature metadata is invalid 离线包签名元数据无效 - + The manifest digest does not match the package signature Manifest 摘要与包签名不一致 - + The offline manifest is invalid 离线 Manifest 无效 - + Package information does not match manifest identity 包信息与 Manifest 身份不一致 - + The offline manifest RSA signature is invalid 离线 Manifest RSA 签名无效 - + The offline package contains an unsafe path or out-of-range data: %1 离线包包含不安全路径或越界数据:%1 - + Cannot prepare offline file: %1 无法准备离线文件:%1 - + Cannot create staged file: %1 无法创建暂存文件:%1 - + Failed to read offline file: %1 离线文件读取失败:%1 - + Offline file hash verification or write failed: %1 离线文件 Hash 或写入失败:%1 - + The offline package file count does not match the manifest 离线包文件数量与 Manifest 不一致 - - Bootstrap - - - Update Handoff Failed - 更新交接失败 - - - - Bootstrap arguments are incomplete. - Bootstrap 启动参数不完整。 - - - - The update plan is missing or invalid. - 更新计划文件缺失或格式无效。 - - - - Cannot restart Updater. - 无法重新启动 Updater。 - - - - -Failed file: %1 - -失败文件:%1 - - diff --git a/scripts/ReadMe.txt b/scripts/ReadMe.txt index d8cab24..8b7367a 100644 --- a/scripts/ReadMe.txt +++ b/scripts/ReadMe.txt @@ -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 diff --git a/scripts/package-client.ps1 b/scripts/package-client.ps1 index fa980c5..4301163 100644 --- a/scripts/package-client.ps1 +++ b/scripts/package-client.ps1 @@ -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 diff --git a/scripts/package-client.sh b/scripts/package-client.sh index 163967f..7ac03dc 100644 --- a/scripts/package-client.sh +++ b/scripts/package-client.sh @@ -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 diff --git a/scripts/package-sdk.ps1 b/scripts/package-sdk.ps1 index 0641913..899333f 100644 --- a/scripts/package-sdk.ps1 +++ b/scripts/package-sdk.ps1 @@ -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 diff --git a/scripts/package-sdk.sh b/scripts/package-sdk.sh index 8a649f4..1cc76e1 100644 --- a/scripts/package-sdk.sh +++ b/scripts/package-sdk.sh @@ -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" <