Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b736472b55 | |||
| b098792001 | |||
| c5e8f34f75 | |||
| bec05cdbc8 | |||
| 9d632f3323 | |||
| fe5aa5bbf0 |
@@ -1,6 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8-bom
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
+2
-4
@@ -22,14 +22,11 @@ Desktop.ini
|
||||
*.pdf
|
||||
*.doc
|
||||
*.docx
|
||||
private/
|
||||
pdf_requirements.txt
|
||||
|
||||
# C/C++ generated artifacts
|
||||
*.obj
|
||||
*.o
|
||||
*.pdb
|
||||
*.qm
|
||||
*.ilk
|
||||
*.idb
|
||||
*.tlog
|
||||
@@ -52,7 +49,6 @@ CMakeUserPresets.json
|
||||
Testing/
|
||||
|
||||
# Third-party/business binary drops and generated packages
|
||||
thirdparty/
|
||||
App/
|
||||
dist/
|
||||
*.zip
|
||||
@@ -63,6 +59,8 @@ dist/
|
||||
*.rar
|
||||
|
||||
# Local runtime state and credentials
|
||||
client.ini
|
||||
config/config.json
|
||||
config/app_config.json
|
||||
config/client_identity.dat
|
||||
config/local_state.json
|
||||
|
||||
+13
-14
@@ -1,20 +1,19 @@
|
||||
project(Bootstrap LANGUAGES CXX)
|
||||
set(_bootstrap_sources main.cpp)
|
||||
if(WIN32)
|
||||
file(GLOB _bootstrap_resource_files CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.rc")
|
||||
list(APPEND _bootstrap_sources ${_bootstrap_resource_files})
|
||||
endif()
|
||||
|
||||
add_executable(Bootstrap
|
||||
main.cpp
|
||||
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||
)
|
||||
add_executable(Bootstrap ${_bootstrap_sources})
|
||||
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
||||
target_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets)
|
||||
set_target_properties(Bootstrap PROPERTIES
|
||||
AUTOMOC OFF
|
||||
AUTOUIC OFF
|
||||
AUTORCC OFF
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Bootstrap PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET Bootstrap POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Bootstrap>
|
||||
COMMENT "Deploy Qt runtime for Bootstrap"
|
||||
)
|
||||
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
|
||||
target_link_libraries(Bootstrap PRIVATE shell32)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma code_page(65001)
|
||||
|
||||
#include <windows.h>
|
||||
#include "resource.h"
|
||||
|
||||
LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL
|
||||
|
||||
STRINGTABLE
|
||||
BEGIN
|
||||
IDS_BOOTSTRAP_ERROR_TITLE "Update handoff failed"
|
||||
IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE "Bootstrap arguments are incomplete."
|
||||
IDS_BOOTSTRAP_INVALID_PLAN_OPERATION "The update plan contains an invalid operation."
|
||||
IDS_BOOTSTRAP_UNSAFE_PLAN_PATH "The update plan contains an unsafe path."
|
||||
IDS_BOOTSTRAP_RESTART_UPDATER_FAILED "Updater.exe could not be restarted."
|
||||
IDS_BOOTSTRAP_FAILED_FILE_PREFIX "Failed file: "
|
||||
END
|
||||
|
||||
LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
|
||||
|
||||
STRINGTABLE
|
||||
BEGIN
|
||||
IDS_BOOTSTRAP_ERROR_TITLE "更新接管失败"
|
||||
IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE "Bootstrap 参数不完整。"
|
||||
IDS_BOOTSTRAP_INVALID_PLAN_OPERATION "更新计划操作格式无效。"
|
||||
IDS_BOOTSTRAP_UNSAFE_PLAN_PATH "更新计划包含不安全路径。"
|
||||
IDS_BOOTSTRAP_RESTART_UPDATER_FAILED "无法重新启动 Updater.exe。"
|
||||
IDS_BOOTSTRAP_FAILED_FILE_PREFIX "失败文件:"
|
||||
END
|
||||
+156
-151
@@ -1,203 +1,208 @@
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QTextStream>
|
||||
#include <QThread>
|
||||
#include <QTranslator>
|
||||
#include <QVector>
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include "resource.h"
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
struct PlanItem
|
||||
{
|
||||
QChar operation;
|
||||
QString relativePath;
|
||||
};
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static void showError(const QString& message)
|
||||
static std::wstring loadLocalizedString(unsigned int id)
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QApplication::translate("Bootstrap", "Update Handoff Failed"),
|
||||
message);
|
||||
const wchar_t* value = nullptr;
|
||||
const int length = LoadStringW(GetModuleHandleW(nullptr), id,
|
||||
reinterpret_cast<LPWSTR>(&value), 0);
|
||||
return length > 0 ? std::wstring(value, static_cast<size_t>(length)) : std::wstring();
|
||||
}
|
||||
|
||||
static QString cleanRelativePath(const QString& value)
|
||||
static void showBootstrapError(unsigned int messageId)
|
||||
{
|
||||
QString path = QDir::fromNativeSeparators(value.trimmed());
|
||||
if (path.isEmpty())
|
||||
return {};
|
||||
const std::wstring message = loadLocalizedString(messageId);
|
||||
const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
|
||||
MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
|
||||
}
|
||||
|
||||
path = QDir::cleanPath(path);
|
||||
if (path == "." || path == ".." || path.startsWith("../")
|
||||
|| path.contains("/../") || QDir::isAbsolutePath(path)
|
||||
|| path.contains(':')) {
|
||||
return {};
|
||||
static std::wstring quote(const std::wstring& value)
|
||||
{
|
||||
std::wstring result = L"\"";
|
||||
unsigned backslashes = 0;
|
||||
for (wchar_t ch : value) {
|
||||
if (ch == L'\\') { ++backslashes; continue; }
|
||||
if (ch == L'\"') {
|
||||
result.append(backslashes * 2 + 1, L'\\');
|
||||
result.push_back(L'\"');
|
||||
backslashes = 0;
|
||||
continue;
|
||||
}
|
||||
result.append(backslashes, L'\\');
|
||||
backslashes = 0;
|
||||
result.push_back(ch);
|
||||
}
|
||||
return path;
|
||||
result.append(backslashes * 2, L'\\');
|
||||
result.push_back(L'\"');
|
||||
return result;
|
||||
}
|
||||
|
||||
static QString joinPath(const QString& root, const QString& relativePath)
|
||||
static std::wstring fromUtf8(const std::string& value)
|
||||
{
|
||||
return QDir(root).filePath(relativePath);
|
||||
if (value.empty()) return {};
|
||||
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
||||
static_cast<int>(value.size()), nullptr, 0);
|
||||
if (size <= 0) return {};
|
||||
std::wstring result(size, L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
||||
static_cast<int>(value.size()), result.data(), size);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool removeWithRetry(const QString& path)
|
||||
static bool safeRelative(const fs::path& path)
|
||||
{
|
||||
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
|
||||
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
|
||||
if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
|
||||
for (const auto& part : path)
|
||||
if (part == L"..") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
|
||||
{
|
||||
std::error_code ec;
|
||||
fs::create_directories(destination.parent_path(), ec);
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
if (!QFileInfo::exists(path))
|
||||
return true;
|
||||
if (QFile::remove(path))
|
||||
return true;
|
||||
QThread::msleep(100);
|
||||
}
|
||||
return !QFileInfo::exists(path);
|
||||
}
|
||||
|
||||
static bool copyWithRetry(const QString& source, const QString& destination)
|
||||
{
|
||||
QDir().mkpath(QFileInfo(destination).absolutePath());
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
QFile::remove(destination);
|
||||
if (QFile::copy(source, destination))
|
||||
return true;
|
||||
QThread::msleep(100);
|
||||
ec.clear();
|
||||
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec);
|
||||
if (!ec) return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool rollback(const QString& installDir, const QString& backupDir,
|
||||
const QVector<QString>& paths)
|
||||
static bool removeWithRetry(const fs::path& path)
|
||||
{
|
||||
std::error_code ec;
|
||||
for (int i = 0; i < 100; ++i) {
|
||||
ec.clear();
|
||||
if (!fs::exists(path, ec)) return !ec;
|
||||
if (fs::remove(path, ec)) return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool rollback(const fs::path& installDir, const fs::path& backupDir,
|
||||
const std::vector<fs::path>& paths)
|
||||
{
|
||||
bool success = true;
|
||||
for (const QString& relativePath : paths) {
|
||||
const QString destination = joinPath(installDir, relativePath);
|
||||
const QString backup = joinPath(backupDir, relativePath);
|
||||
if (QFileInfo::exists(backup)) {
|
||||
if (!copyWithRetry(backup, destination))
|
||||
success = false;
|
||||
} else if (QFileInfo::exists(destination) && !removeWithRetry(destination)) {
|
||||
for (const auto& relative : paths) {
|
||||
const fs::path destination = installDir / relative;
|
||||
const fs::path backup = backupDir / relative;
|
||||
std::error_code ec;
|
||||
if (fs::exists(backup, ec)) {
|
||||
if (!copyWithRetry(backup, destination)) success = false;
|
||||
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
static bool launchUpdater(const QString& updater, const QStringList& updateArgs,
|
||||
const QString& result)
|
||||
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
|
||||
const std::wstring& result)
|
||||
{
|
||||
QStringList args = updateArgs;
|
||||
args.append(QStringLiteral("--bootstrap-resume=%1").arg(result));
|
||||
return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath());
|
||||
std::wstring command = quote(updater);
|
||||
for (const auto& arg : updateArgs) command += L" " + quote(arg);
|
||||
command += L" " + quote(L"--bootstrap-resume=" + result);
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
std::vector<wchar_t> mutableCommand(command.begin(), command.end());
|
||||
mutableCommand.push_back(L'\0');
|
||||
const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr,
|
||||
FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi);
|
||||
if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); }
|
||||
return ok == TRUE;
|
||||
}
|
||||
|
||||
static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths)
|
||||
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
||||
{
|
||||
QFile file(planFile);
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return false;
|
||||
|
||||
QTextStream input(&file);
|
||||
input.setCodec("UTF-8");
|
||||
while (!input.atEnd()) {
|
||||
const QString line = input.readLine();
|
||||
if (line.size() < 3 || line.at(1) != QLatin1Char('\t')
|
||||
|| (line.at(0) != QLatin1Char('C') && line.at(0) != QLatin1Char('D'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString relativePath = cleanRelativePath(line.mid(2));
|
||||
if (relativePath.isEmpty())
|
||||
return false;
|
||||
|
||||
items->append({line.at(0), relativePath});
|
||||
paths->append(relativePath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool isBootstrapSelfPath(const QString& relativePath)
|
||||
{
|
||||
const QString fileName = QFileInfo(relativePath).fileName();
|
||||
return fileName.compare(QStringLiteral("Bootstrap.exe"), Qt::CaseInsensitive) == 0
|
||||
|| fileName.compare(QStringLiteral("Bootstrap"), Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
|
||||
QTranslator translator;
|
||||
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
|
||||
app.installTranslator(&translator);
|
||||
|
||||
const QStringList arguments = QCoreApplication::arguments();
|
||||
if (arguments.size() < 11) {
|
||||
showError(QApplication::translate("Bootstrap", "Bootstrap arguments are incomplete."));
|
||||
int argc = 0;
|
||||
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
|
||||
if (!argv || argc < 11) {
|
||||
showBootstrapError(IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE);
|
||||
if (argv) LocalFree(argv);
|
||||
return 2;
|
||||
}
|
||||
const fs::path planFile = argv[1];
|
||||
const fs::path installDir = argv[2];
|
||||
const fs::path stagingDir = argv[3];
|
||||
const fs::path backupDir = argv[4];
|
||||
const std::wstring updater = argv[5];
|
||||
const DWORD updaterPid = static_cast<DWORD>(_wtoi(argv[6]));
|
||||
std::vector<std::wstring> updateArgs{argv[7], argv[8], argv[9], argv[10]};
|
||||
const std::wstring mode = argc >= 12 ? argv[11] : L"install";
|
||||
LocalFree(argv);
|
||||
|
||||
const QString planFile = arguments.at(1);
|
||||
const QString installDir = arguments.at(2);
|
||||
const QString stagingDir = arguments.at(3);
|
||||
const QString backupDir = arguments.at(4);
|
||||
const QString updater = arguments.at(5);
|
||||
const QString updaterPid = arguments.at(6);
|
||||
Q_UNUSED(updaterPid);
|
||||
const QStringList updateArgs{arguments.at(7), arguments.at(8), arguments.at(9), arguments.at(10)};
|
||||
const QString mode = arguments.size() >= 12 ? arguments.at(11) : QStringLiteral("install");
|
||||
|
||||
QThread::msleep(500);
|
||||
|
||||
QVector<PlanItem> items;
|
||||
QVector<QString> paths;
|
||||
if (!readPlan(planFile, &items, &paths)) {
|
||||
showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid."));
|
||||
return 3;
|
||||
if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
|
||||
WaitForSingleObject(process, 30000);
|
||||
CloseHandle(process);
|
||||
}
|
||||
|
||||
bool success = true;
|
||||
struct PlanItem { wchar_t operation; fs::path relative; };
|
||||
std::ifstream input(planFile, std::ios::binary);
|
||||
std::vector<PlanItem> items;
|
||||
std::vector<fs::path> paths;
|
||||
std::string line;
|
||||
while (std::getline(input, line)) {
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
|
||||
showBootstrapError(IDS_BOOTSTRAP_INVALID_PLAN_OPERATION);
|
||||
return 3;
|
||||
}
|
||||
const fs::path relative(fromUtf8(line.substr(2)));
|
||||
if (!safeRelative(relative)) {
|
||||
showBootstrapError(IDS_BOOTSTRAP_UNSAFE_PLAN_PATH);
|
||||
return 3;
|
||||
}
|
||||
items.push_back({static_cast<wchar_t>(line[0]), relative});
|
||||
paths.push_back(relative);
|
||||
}
|
||||
|
||||
bool success = input.eof();
|
||||
bool rolledBack = false;
|
||||
QString failedPath;
|
||||
if (mode == QStringLiteral("rollback")) {
|
||||
rolledBack = rollback(installDir, backupDir, paths);
|
||||
fs::path failedPath;
|
||||
if (mode == L"rollback") {
|
||||
rolledBack = success && rollback(installDir, backupDir, paths);
|
||||
success = false;
|
||||
} else {
|
||||
// Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
|
||||
// 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
|
||||
for (const PlanItem& item : items) {
|
||||
const QString& relativePath = item.relativePath;
|
||||
if (isBootstrapSelfPath(relativePath)) {
|
||||
} else if (success) {
|
||||
for (const auto& item : items) {
|
||||
const fs::path& relative = item.relative;
|
||||
if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) {
|
||||
success = false;
|
||||
failedPath = relativePath;
|
||||
failedPath = relative;
|
||||
break;
|
||||
}
|
||||
|
||||
const bool itemOk = item.operation == QLatin1Char('C')
|
||||
? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath))
|
||||
: removeWithRetry(joinPath(installDir, relativePath));
|
||||
const bool itemOk = item.operation == L'C'
|
||||
? copyWithRetry(stagingDir / relative, installDir / relative)
|
||||
: removeWithRetry(installDir / relative);
|
||||
if (!itemOk) {
|
||||
success = false;
|
||||
failedPath = relativePath;
|
||||
failedPath = relative;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!success)
|
||||
rolledBack = rollback(installDir, backupDir, paths);
|
||||
if (!success) rolledBack = rollback(installDir, backupDir, paths);
|
||||
}
|
||||
|
||||
const QString result = success ? QStringLiteral("success")
|
||||
: (rolledBack ? QStringLiteral("rolledback") : QStringLiteral("rollback-failed"));
|
||||
const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
|
||||
if (!launchUpdater(updater, updateArgs, result)) {
|
||||
QString message = QApplication::translate("Bootstrap", "Cannot restart Updater.");
|
||||
if (!failedPath.isEmpty()) {
|
||||
message += QApplication::translate("Bootstrap", "\nFailed file: %1")
|
||||
.arg(failedPath);
|
||||
}
|
||||
showError(message);
|
||||
std::wstring message = loadLocalizedString(IDS_BOOTSTRAP_RESTART_UPDATER_FAILED);
|
||||
if (!failedPath.empty())
|
||||
message += L"\n" + loadLocalizedString(IDS_BOOTSTRAP_FAILED_FILE_PREFIX) + failedPath.wstring();
|
||||
const std::wstring title = loadLocalizedString(IDS_BOOTSTRAP_ERROR_TITLE);
|
||||
MessageBoxW(nullptr, message.c_str(), title.c_str(), MB_ICONERROR);
|
||||
return 4;
|
||||
}
|
||||
return (success || rolledBack) ? 0 : 5;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
|
||||
#define IDS_BOOTSTRAP_ERROR_TITLE 1000
|
||||
#define IDS_BOOTSTRAP_ARGUMENTS_INCOMPLETE 1001
|
||||
#define IDS_BOOTSTRAP_INVALID_PLAN_OPERATION 1002
|
||||
#define IDS_BOOTSTRAP_UNSAFE_PLAN_PATH 1003
|
||||
#define IDS_BOOTSTRAP_RESTART_UPDATER_FAILED 1004
|
||||
#define IDS_BOOTSTRAP_FAILED_FILE_PREFIX 1005
|
||||
+136
-127
@@ -1,157 +1,166 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(ClientAll LANGUAGES C CXX)
|
||||
|
||||
project(SimCAEUpdateClient LANGUAGES C CXX)
|
||||
|
||||
set(UPDATE_CLIENT_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set(UPDATE_CLIENT_IS_STANDALONE OFF)
|
||||
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
|
||||
set(UPDATE_CLIENT_IS_STANDALONE ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
if(MSVC)
|
||||
# Source files should be saved as UTF-8 with BOM; compile source and
|
||||
# execution charsets as UTF-8 instead of setting encodings in code.
|
||||
add_compile_options(/utf-8)
|
||||
endif()
|
||||
|
||||
# Qt global config. Qt is intentionally not hard-coded here; configure it with
|
||||
# CMake-recognized environment variables such as CMAKE_PREFIX_PATH or Qt5_DIR.
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
||||
|
||||
get_target_property(QT_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_DIR "${QT_QMAKE_EXECUTABLE}" DIRECTORY)
|
||||
find_program(QT_LRELEASE_EXECUTABLE NAMES lrelease lrelease.exe HINTS "${QT_BIN_DIR}" REQUIRED)
|
||||
find_program(QT_LUPDATE_EXECUTABLE NAMES lupdate lupdate.exe HINTS "${QT_BIN_DIR}" REQUIRED)
|
||||
if(MSVC)
|
||||
add_compile_options(/utf-8)
|
||||
endif()
|
||||
|
||||
set(TRANSLATION_TS "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.ts")
|
||||
set(TRANSLATION_QM "${CMAKE_SOURCE_DIR}/i18n/update-client_zh_CN.qm")
|
||||
set(TRANSLATION_SCAN_DIRS
|
||||
"${CMAKE_SOURCE_DIR}/Common"
|
||||
"${CMAKE_SOURCE_DIR}/Bootstrap"
|
||||
"${CMAKE_SOURCE_DIR}/Launcher"
|
||||
"${CMAKE_SOURCE_DIR}/Updater"
|
||||
"${CMAKE_SOURCE_DIR}/MainApp"
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_SOURCE_DIR
|
||||
BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/.."
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _update_client_source_root
|
||||
)
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT "${TRANSLATION_QM}"
|
||||
COMMAND "${QT_LRELEASE_EXECUTABLE}" "${TRANSLATION_TS}" -qm "${TRANSLATION_QM}"
|
||||
DEPENDS "${TRANSLATION_TS}"
|
||||
COMMENT "Compile Qt translation: update-client_zh_CN.qm"
|
||||
VERBATIM
|
||||
cmake_path(GET _update_client_source_root PARENT_PATH _update_client_parent_root)
|
||||
set(
|
||||
UPDATE_CLIENT_THIRDPARTY_ROOT
|
||||
"${_update_client_parent_root}/3rdparty"
|
||||
CACHE PATH
|
||||
"Root directory for update-client third-party packages"
|
||||
)
|
||||
add_custom_target(update_client_translations ALL
|
||||
DEPENDS "${TRANSLATION_QM}"
|
||||
option(
|
||||
UPDATE_CLIENT_STRICT_THIRDPARTY
|
||||
"Require OpenSSL imported library and include paths to stay under the third-party root"
|
||||
ON
|
||||
)
|
||||
|
||||
add_custom_target(update_client_lupdate
|
||||
COMMAND "${QT_LUPDATE_EXECUTABLE}"
|
||||
${TRANSLATION_SCAN_DIRS}
|
||||
-extensions cpp,h
|
||||
-no-obsolete
|
||||
-ts "${TRANSLATION_TS}"
|
||||
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
|
||||
COMMENT "Update Qt translation source: update-client_zh_CN.ts"
|
||||
VERBATIM
|
||||
set(
|
||||
UPDATE_CLIENT_PRODUCT_CONFIG_FILE
|
||||
""
|
||||
CACHE FILEPATH
|
||||
"Exact product config to embed; empty selects config/config.json then the example"
|
||||
)
|
||||
set(
|
||||
UPDATE_CLIENT_LAUNCH_TOKEN
|
||||
""
|
||||
CACHE STRING
|
||||
"Optional build-provided launch ticket token override"
|
||||
)
|
||||
mark_as_advanced(UPDATE_CLIENT_LAUNCH_TOKEN)
|
||||
set(UPDATE_CLIENT_MAIN_EXECUTABLE "" CACHE STRING "Override main_executable in embedded config")
|
||||
set(UPDATE_CLIENT_LAUNCHER_EXECUTABLE "" CACHE STRING "Override launcher_executable in embedded config")
|
||||
set(UPDATE_CLIENT_UPDATER_EXECUTABLE "" CACHE STRING "Override updater_executable in embedded config")
|
||||
set(UPDATE_CLIENT_BOOTSTRAP_EXECUTABLE "" CACHE STRING "Override bootstrap_executable in embedded config")
|
||||
set(UPDATE_CLIENT_PLATFORM "" CACHE STRING "Override platform in embedded config")
|
||||
set(UPDATE_CLIENT_ARCH "" CACHE STRING "Override arch in embedded config")
|
||||
|
||||
# Local-only third-party dependencies. The thirdparty directory is ignored by Git.
|
||||
# Windows can use thirdparty/OpenSSL-Win64. Linux normally uses system OpenSSL.
|
||||
set(THIRDPARTY_DIR "${CMAKE_SOURCE_DIR}/thirdparty" CACHE PATH "Local third-party dependency root")
|
||||
if(WIN32)
|
||||
set(SIMCAE_OPENSSL_ROOT "${THIRDPARTY_DIR}/OpenSSL-Win64" CACHE PATH "OpenSSL Win64 root")
|
||||
if(NOT EXISTS "${SIMCAE_OPENSSL_ROOT}/include/openssl")
|
||||
message(FATAL_ERROR
|
||||
"OpenSSL not found: ${SIMCAE_OPENSSL_ROOT}\n"
|
||||
"Copy OpenSSL-Win64 to thirdparty/OpenSSL-Win64, or configure with "
|
||||
"-DSIMCAE_OPENSSL_ROOT=<OpenSSL-Win64 root>."
|
||||
)
|
||||
set(_update_client_deploy_qt_default ON)
|
||||
else()
|
||||
set(_update_client_deploy_qt_default OFF)
|
||||
endif()
|
||||
option(
|
||||
UPDATE_CLIENT_DEPLOY_QT_RUNTIME
|
||||
"Run the Qt deployment tool after building each Qt executable"
|
||||
${_update_client_deploy_qt_default}
|
||||
)
|
||||
option(
|
||||
UPDATE_CLIENT_REQUIRE_TRANSLATIONS
|
||||
"Fail configuration when translations/update-client_zh_CN.ts is absent"
|
||||
OFF
|
||||
)
|
||||
option(
|
||||
UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS
|
||||
"Install update-client runtime assets from this project"
|
||||
${UPDATE_CLIENT_IS_STANDALONE}
|
||||
)
|
||||
option(
|
||||
UPDATE_CLIENT_BUILD_DEMO_APP
|
||||
"Build the standalone MainApp integration demo"
|
||||
${UPDATE_CLIENT_IS_STANDALONE}
|
||||
)
|
||||
|
||||
list(PREPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
|
||||
include(UpdateClientDependencies)
|
||||
include(UpdateClientResources)
|
||||
|
||||
update_client_find_dependencies()
|
||||
update_client_select_product_config(_update_client_product_config_source)
|
||||
update_client_generate_normalized_product_config(
|
||||
_update_client_product_config
|
||||
"${_update_client_product_config_source}"
|
||||
)
|
||||
update_client_create_embedded_resources(
|
||||
UpdateClientEmbeddedResources
|
||||
"${_update_client_product_config}"
|
||||
)
|
||||
|
||||
add_custom_target(UpdateClientNoHanCheck ALL
|
||||
COMMAND "${CMAKE_COMMAND}"
|
||||
"-DUPDATE_CLIENT_SCAN_ROOT=${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
-P "${CMAKE_CURRENT_SOURCE_DIR}/cmake/CheckNoHan.cmake"
|
||||
COMMENT "Checking update-client production sources for Han characters"
|
||||
VERBATIM
|
||||
)
|
||||
add_dependencies(UpdateClientEmbeddedResources UpdateClientNoHanCheck)
|
||||
|
||||
if(UPDATE_CLIENT_IS_STANDALONE)
|
||||
if(NOT CMAKE_ARCHIVE_OUTPUT_DIRECTORY)
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/out/lib")
|
||||
endif()
|
||||
if(NOT CMAKE_LIBRARY_OUTPUT_DIRECTORY)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/out/bin")
|
||||
endif()
|
||||
if(NOT CMAKE_RUNTIME_OUTPUT_DIRECTORY)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/out/bin")
|
||||
endif()
|
||||
set(OPENSSL_INC "${SIMCAE_OPENSSL_ROOT}/include")
|
||||
set(OPENSSL_LIB_DEBUG "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MDd")
|
||||
set(OPENSSL_LIB_RELEASE "${SIMCAE_OPENSSL_ROOT}/lib/VC/x64/MD")
|
||||
foreach(OPENSSL_LIB_DIR IN ITEMS "${OPENSSL_LIB_DEBUG}" "${OPENSSL_LIB_RELEASE}")
|
||||
if(NOT EXISTS "${OPENSSL_LIB_DIR}")
|
||||
message(FATAL_ERROR "OpenSSL library directory not found: ${OPENSSL_LIB_DIR}")
|
||||
endif()
|
||||
endforeach()
|
||||
set(SIMCAE_OPENSSL_LINK_LIBRARIES
|
||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libssl.lib>
|
||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}/libcrypto.lib>
|
||||
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libssl.lib>
|
||||
$<$<NOT:$<CONFIG:Debug>>:${OPENSSL_LIB_RELEASE}/libcrypto.lib>
|
||||
)
|
||||
else()
|
||||
find_package(OpenSSL REQUIRED)
|
||||
set(OPENSSL_INC "${OPENSSL_INCLUDE_DIR}")
|
||||
set(SIMCAE_OPENSSL_LINK_LIBRARIES OpenSSL::SSL OpenSSL::Crypto)
|
||||
endif()
|
||||
|
||||
add_compile_definitions(HAVE_OPENSSL=1)
|
||||
|
||||
# 统一输出目录。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()
|
||||
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out)
|
||||
endif()
|
||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/lib)
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${SIMCAE_OUTPUT_ROOT}/bin)
|
||||
|
||||
# Bootstrap 使用 Qt 实现跨平台更新交接,避免直接依赖 Win32 API。
|
||||
add_subdirectory(Bootstrap)
|
||||
|
||||
# 先编译公共库
|
||||
add_subdirectory(Common)
|
||||
|
||||
# 再编译三个业务程序
|
||||
add_library(UpdateClientUpdaterLogic STATIC
|
||||
Updater/UpdaterLogic.h
|
||||
Updater/UpdaterLogic.cpp
|
||||
)
|
||||
target_compile_features(UpdateClientUpdaterLogic PUBLIC cxx_std_17)
|
||||
target_include_directories(UpdateClientUpdaterLogic PUBLIC
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/Updater"
|
||||
)
|
||||
target_link_libraries(UpdateClientUpdaterLogic
|
||||
PUBLIC
|
||||
Common
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
|
||||
add_subdirectory(Launcher)
|
||||
add_subdirectory(Updater)
|
||||
add_subdirectory(MainApp)
|
||||
|
||||
# 默认启动项目
|
||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
|
||||
# Copy local-only static resources to output bin folder. Final customer
|
||||
# app_config.json is generated by the Go server when a release package is
|
||||
# uploaded. Developers may create an untracked config/app_config.local.json for
|
||||
# local debugging.
|
||||
set(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
|
||||
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
|
||||
set(LOCAL_APP_CONFIG_FILE "${CONFIG_SOURCE_DIR}/app_config.local.json")
|
||||
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
|
||||
|
||||
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
|
||||
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
|
||||
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
|
||||
if(EXISTS "${LOCAL_APP_CONFIG_FILE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
|
||||
configure_file("${LOCAL_APP_CONFIG_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
|
||||
set(_update_client_primary_targets
|
||||
Bootstrap
|
||||
Common
|
||||
UpdateClientUpdaterLogic
|
||||
Launcher
|
||||
Updater
|
||||
)
|
||||
if(UPDATE_CLIENT_BUILD_DEMO_APP)
|
||||
add_subdirectory(MainApp)
|
||||
list(APPEND _update_client_primary_targets MainApp)
|
||||
endif()
|
||||
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
|
||||
if(EXISTS "${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" AND NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
|
||||
configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
|
||||
endif()
|
||||
|
||||
foreach(_update_client_primary_target IN LISTS _update_client_primary_targets)
|
||||
add_dependencies("${_update_client_primary_target}" UpdateClientNoHanCheck)
|
||||
endforeach()
|
||||
|
||||
# 编译阶段同步静态配置资源
|
||||
add_custom_target(copy_client_resources ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem
|
||||
COMMENT "Copy client config directory and manifest public key to output bin folder"
|
||||
)
|
||||
add_dependencies(Launcher copy_client_resources)
|
||||
add_dependencies(Updater copy_client_resources)
|
||||
add_dependencies(MainApp copy_client_resources)
|
||||
add_dependencies(Launcher update_client_translations)
|
||||
add_dependencies(Updater update_client_translations)
|
||||
add_dependencies(MainApp update_client_translations)
|
||||
add_dependencies(Bootstrap update_client_translations)
|
||||
set_property(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
|
||||
# Install rules for packaging
|
||||
if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
|
||||
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
|
||||
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
|
||||
if(UPDATE_CLIENT_INSTALL_RUNTIME_ASSETS)
|
||||
update_client_install_openssl_runtime(bin)
|
||||
endif()
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "windows-x64-base",
|
||||
"displayName": "Windows x64 Base",
|
||||
"description": "Windows x64 build with Visual Studio 2022.",
|
||||
"hidden": true,
|
||||
"generator": "Visual Studio 17 2022",
|
||||
"architecture": {
|
||||
"value": "x64",
|
||||
"strategy": "set"
|
||||
},
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Windows"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linux-x64-base",
|
||||
"displayName": "Linux x64 Base",
|
||||
"description": "Linux x64 build with system Qt and OpenSSL.",
|
||||
"hidden": true,
|
||||
"generator": "Unix Makefiles",
|
||||
"binaryDir": "${sourceDir}/out/build/${presetName}",
|
||||
"installDir": "${sourceDir}/out/install/${presetName}",
|
||||
"condition": {
|
||||
"type": "equals",
|
||||
"lhs": "${hostSystemName}",
|
||||
"rhs": "Linux"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x64-debug",
|
||||
"displayName": "x64 Debug",
|
||||
"description": "Debug build for Windows x64.",
|
||||
"inherits": "windows-x64-base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_CONFIGURATION_TYPES": "Debug",
|
||||
"CMAKE_INSTALL_CONFIG_NAME": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x64-release",
|
||||
"displayName": "x64 Release",
|
||||
"description": "Release build for Windows x64.",
|
||||
"inherits": "windows-x64-base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_CONFIGURATION_TYPES": "Release",
|
||||
"CMAKE_INSTALL_CONFIG_NAME": "Release"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linux-x64-debug",
|
||||
"displayName": "Linux x64 Debug",
|
||||
"description": "Debug build for Linux x64.",
|
||||
"inherits": "linux-x64-base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "linux-x64-release",
|
||||
"displayName": "Linux x64 Release",
|
||||
"description": "Release build for Linux x64.",
|
||||
"inherits": "linux-x64-base",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "x64-debug",
|
||||
"displayName": "x64 Debug",
|
||||
"configurePreset": "x64-debug",
|
||||
"configuration": "Debug"
|
||||
},
|
||||
{
|
||||
"name": "x64-release",
|
||||
"displayName": "x64 Release",
|
||||
"configurePreset": "x64-release",
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "linux-x64-debug",
|
||||
"displayName": "Linux x64 Debug",
|
||||
"configurePreset": "linux-x64-debug"
|
||||
},
|
||||
{
|
||||
"name": "linux-x64-release",
|
||||
"displayName": "Linux x64 Release",
|
||||
"configurePreset": "linux-x64-release"
|
||||
}
|
||||
]
|
||||
}
|
||||
+18
-18
@@ -1,36 +1,36 @@
|
||||
project(Common LANGUAGES C CXX)
|
||||
|
||||
set(SRC
|
||||
set(_common_sources
|
||||
HttpHelper.h
|
||||
HttpHelper.cpp
|
||||
FileHelper.h
|
||||
FileHelper.cpp
|
||||
ConfigHelper.h
|
||||
ConfigHelper.cpp
|
||||
InitialStatePolicy.h
|
||||
ManifestBootstrapPolicy.h
|
||||
RuntimeProtectionPolicy.h
|
||||
PolicyHelper.h
|
||||
PolicyHelper.cpp
|
||||
LocalStateHelper.h
|
||||
LocalStateHelper.cpp
|
||||
TicketHelper.h
|
||||
TicketHelper.cpp
|
||||
UpdatePathPolicy.h
|
||||
UpdatePathPolicy.cpp
|
||||
IntegrityHelper.h
|
||||
IntegrityHelper.cpp
|
||||
)
|
||||
add_library(Common STATIC ${SRC})
|
||||
|
||||
# Common编译自身需要OpenSSL头文件
|
||||
target_include_directories(Common
|
||||
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||
PRIVATE ${OPENSSL_INC}
|
||||
DeviceIdentityHelper.h
|
||||
DeviceIdentityHelper.cpp
|
||||
TranslationHelper.h
|
||||
TranslationHelper.cpp
|
||||
)
|
||||
|
||||
add_library(Common STATIC ${_common_sources})
|
||||
target_compile_features(Common PUBLIC cxx_std_17)
|
||||
target_compile_definitions(Common PUBLIC HAVE_OPENSSL=1)
|
||||
target_include_directories(Common PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(Common
|
||||
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
|
||||
PUBLIC ${SIMCAE_OPENSSL_LINK_LIBRARIES}
|
||||
PUBLIC
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Widgets
|
||||
OpenSSL::SSL
|
||||
OpenSSL::Crypto
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
target_link_libraries(Common INTERFACE shell32)
|
||||
endif()
|
||||
|
||||
+551
-766
File diff suppressed because it is too large
Load Diff
+15
-25
@@ -1,45 +1,35 @@
|
||||
#pragma once
|
||||
#include <QByteArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
class QSettings;
|
||||
|
||||
class ConfigHelper
|
||||
{
|
||||
public:
|
||||
static ConfigHelper& instance();
|
||||
static int runElevatedWriteCommandIfRequested();
|
||||
static QString executableNameForCurrentPlatform(const QString& configuredValue,
|
||||
const QString& fallbackBaseName);
|
||||
static bool writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
|
||||
QString* errorMessage = nullptr);
|
||||
static bool removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage = nullptr);
|
||||
QString getValue(const QString& section, const QString& key) const;
|
||||
bool setValue(const QString& section, const QString& key, const QString& value);
|
||||
QString configPath() const;
|
||||
QString productConfigPath() const;
|
||||
QString stateLocation() 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;
|
||||
|
||||
private:
|
||||
ConfigHelper();
|
||||
bool migrateLegacyIniIfNeeded();
|
||||
void enterRegistryGroup(QSettings& settings) const;
|
||||
bool syncRegistryFromConfigFileIfChanged();
|
||||
bool sanitizeConfigFileAfterImport(QSettings& settings);
|
||||
bool removeRegistryValue(const QString& key) const;
|
||||
QString readEmbeddedValue(const QString& key) const;
|
||||
bool readRegistryValue(const QString& key, QString* value) const;
|
||||
bool writeRegistryValue(const QString& key, const QString& value);
|
||||
QString readFileValue(const QString& key) const;
|
||||
QString m_configPath;
|
||||
QString m_registryInstallId;
|
||||
bool loadProductConfig();
|
||||
bool migrateLegacyStateIfNeeded();
|
||||
bool initializeStateDefaults();
|
||||
QString stateStorageKey(const QString& key) const;
|
||||
QString stateValue(const QString& key) const;
|
||||
bool writeStateValue(const QString& key, const QString& value);
|
||||
static bool isMutableKey(const QString& key);
|
||||
|
||||
QString m_productConfigPath;
|
||||
QString m_statePrefix;
|
||||
QJsonObject m_productConfig;
|
||||
QJsonObject m_initialState;
|
||||
QString m_error;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
#include "DeviceIdentityHelper.h"
|
||||
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QSaveFile>
|
||||
#include <QSysInfo>
|
||||
#include <QTimer>
|
||||
#include <QUuid>
|
||||
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
QString machineHash(const QString& installationId)
|
||||
{
|
||||
const QByteArray identity =
|
||||
QSysInfo::machineUniqueId() + installationId.toUtf8();
|
||||
return QString::fromLatin1(
|
||||
QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex());
|
||||
}
|
||||
}
|
||||
|
||||
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 = "OpenSSL unavailable";
|
||||
return false;
|
||||
#else
|
||||
QFile keyFile(
|
||||
QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = "embedded device public key missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio
|
||||
? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr)
|
||||
: nullptr;
|
||||
if (bio)
|
||||
BIO_free(bio);
|
||||
if (!key)
|
||||
{
|
||||
m_error = "device public key invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
EVP_MD_CTX* context = EVP_MD_CTX_new();
|
||||
const QByteArray signature =
|
||||
QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
const bool valid =
|
||||
context
|
||||
&& EVP_DigestVerifyInit(
|
||||
context, nullptr, EVP_sha256(), nullptr, key)
|
||||
== 1
|
||||
&& EVP_DigestVerifyUpdate(
|
||||
context, payload.constData(), payload.size())
|
||||
== 1
|
||||
&& EVP_DigestVerifyFinal(
|
||||
context,
|
||||
reinterpret_cast<const unsigned char*>(signature.constData()),
|
||||
signature.size())
|
||||
== 1;
|
||||
|
||||
if (context)
|
||||
EVP_MD_CTX_free(context);
|
||||
EVP_PKEY_free(key);
|
||||
if (!valid)
|
||||
m_error = "device credential RSA signature invalid";
|
||||
return valid;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::loadAndVerify(const QString& expectedAppId,
|
||||
const QString& expectedChannel)
|
||||
{
|
||||
QFile credential(
|
||||
QDir(m_installDir).filePath("config/client_identity.dat"));
|
||||
if (!credential.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = "device credential is missing or unreadable";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError wrapperError;
|
||||
const QJsonDocument wrapperDocument =
|
||||
QJsonDocument::fromJson(credential.readAll(), &wrapperError);
|
||||
if (wrapperError.error != QJsonParseError::NoError
|
||||
|| !wrapperDocument.isObject())
|
||||
{
|
||||
m_error = "device credential JSON invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject wrapper = wrapperDocument.object();
|
||||
const QByteArray identityText =
|
||||
wrapper.value("identity_text").toString().toUtf8();
|
||||
if (identityText.isEmpty()
|
||||
|| !verifySignature(
|
||||
identityText, wrapper.value("signature").toString()))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError identityError;
|
||||
const QJsonDocument identityDocument =
|
||||
QJsonDocument::fromJson(identityText, &identityError);
|
||||
if (identityError.error != QJsonParseError::NoError
|
||||
|| !identityDocument.isObject())
|
||||
{
|
||||
m_error = "signed device identity JSON invalid";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject identity = identityDocument.object();
|
||||
const QString signedInstallationId =
|
||||
identity.value("installation_id").toString();
|
||||
const QString signedDeviceId = identity.value("device_id").toString();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
const QString localInstallationId =
|
||||
config.getValue("Device", "installation_id").trimmed();
|
||||
const QString localDeviceId =
|
||||
config.getValue("Update", "device_id").trimmed();
|
||||
|
||||
if (identity.value("app_id").toString() != expectedAppId
|
||||
|| identity.value("channel").toString() != expectedChannel
|
||||
|| identity.value("license_id").toString().isEmpty()
|
||||
|| signedInstallationId.isEmpty() || signedDeviceId.isEmpty())
|
||||
{
|
||||
m_error = "device/license credential identity mismatch";
|
||||
return false;
|
||||
}
|
||||
if (localInstallationId.isEmpty()
|
||||
|| signedInstallationId != localInstallationId)
|
||||
{
|
||||
m_error = "device credential belongs to another installation";
|
||||
return false;
|
||||
}
|
||||
if (!localDeviceId.isEmpty() && signedDeviceId != localDeviceId)
|
||||
{
|
||||
m_error = "device credential does not match the registered device";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString signedMachineHash =
|
||||
identity.value("machine_hash").toString();
|
||||
if (!signedMachineHash.isEmpty()
|
||||
&& signedMachineHash.compare(
|
||||
machineHash(localInstallationId), Qt::CaseInsensitive)
|
||||
!= 0)
|
||||
{
|
||||
m_error = "device credential belongs to another machine";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QDateTime expiry = QDateTime::fromString(
|
||||
identity.value("valid_until").toString(), Qt::ISODate);
|
||||
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc())
|
||||
{
|
||||
m_error = "license expired";
|
||||
return false;
|
||||
}
|
||||
|
||||
m_deviceId = signedDeviceId;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,
|
||||
const QString& channel)
|
||||
{
|
||||
m_error.clear();
|
||||
m_deviceId.clear();
|
||||
return loadAndVerify(appId, channel);
|
||||
}
|
||||
|
||||
bool DeviceIdentityHelper::ensureIssued(const QString& apiBaseUrl,
|
||||
const QString& clientToken,
|
||||
const QString& appId,
|
||||
const QString& channel,
|
||||
const QString& licenseKey)
|
||||
{
|
||||
m_error.clear();
|
||||
m_deviceId.clear();
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
if (loadAndVerify(appId, channel))
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
const QString server = apiBaseUrl.trimmed();
|
||||
if (appId.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "app_id is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (channel.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "channel is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (server.isEmpty()
|
||||
|| server.contains("YOUR_SERVER_IP", Qt::CaseInsensitive))
|
||||
{
|
||||
m_error = "api_base_url is not configured";
|
||||
return false;
|
||||
}
|
||||
if (clientToken.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "client_token is empty in the embedded product configuration";
|
||||
return false;
|
||||
}
|
||||
if (licenseKey.trimmed().isEmpty())
|
||||
{
|
||||
m_error = "license_key is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
QString installationId =
|
||||
config.getValue("Device", "installation_id").trimmed();
|
||||
if (installationId.isEmpty())
|
||||
{
|
||||
installationId =
|
||||
QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
if (!config.setValue("Device", "installation_id", installationId))
|
||||
{
|
||||
m_error = QString("cannot save installation id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonObject requestBody{
|
||||
{"app_id", appId},
|
||||
{"channel", channel},
|
||||
{"license_key", licenseKey},
|
||||
{"installation_id", installationId},
|
||||
{"machine_hash", machineHash(installationId)}};
|
||||
QNetworkAccessManager manager;
|
||||
QNetworkRequest request{QUrl(server + "/api/v1/device/issue")};
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||
QNetworkReply* reply = manager.post(
|
||||
request, QJsonDocument(requestBody).toJson(QJsonDocument::Compact));
|
||||
|
||||
QEventLoop loop;
|
||||
bool timeoutValid = false;
|
||||
int timeoutMs =
|
||||
config.getValue("Update", "request_timeout_ms").toInt(&timeoutValid);
|
||||
if (!timeoutValid || 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();
|
||||
|
||||
const int status =
|
||||
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray responseBytes = reply->readAll();
|
||||
const QString networkError = reply->errorString();
|
||||
reply->deleteLater();
|
||||
if (status != 200)
|
||||
{
|
||||
m_error = QString("device issue failed (HTTP %1): %2 %3")
|
||||
.arg(status)
|
||||
.arg(networkError, QString::fromUtf8(responseBytes));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError responseError;
|
||||
const QJsonDocument responseDocument =
|
||||
QJsonDocument::fromJson(responseBytes, &responseError);
|
||||
if (responseError.error != QJsonParseError::NoError
|
||||
|| !responseDocument.isObject())
|
||||
{
|
||||
m_error = "device issue response is invalid JSON";
|
||||
return false;
|
||||
}
|
||||
const QJsonObject response = responseDocument.object();
|
||||
const QJsonObject wrapper{
|
||||
{"identity_text", response.value("identity_text")},
|
||||
{"signature", response.value("signature")}};
|
||||
if (!wrapper.value("identity_text").isString()
|
||||
|| wrapper.value("identity_text").toString().isEmpty()
|
||||
|| !wrapper.value("signature").isString()
|
||||
|| wrapper.value("signature").toString().isEmpty())
|
||||
{
|
||||
m_error = "device issue response is missing signed identity fields";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString credentialPath =
|
||||
QDir(m_installDir).filePath("config/client_identity.dat");
|
||||
if (!QDir().mkpath(QFileInfo(credentialPath).path()))
|
||||
{
|
||||
m_error = "cannot create the device credential directory";
|
||||
return false;
|
||||
}
|
||||
QSaveFile output(credentialPath);
|
||||
const QByteArray credentialBytes =
|
||||
QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
if (!output.open(QIODevice::WriteOnly)
|
||||
|| output.write(credentialBytes) != credentialBytes.size()
|
||||
|| !output.commit())
|
||||
{
|
||||
m_error = QString("cannot save device credential to %1: %2")
|
||||
.arg(credentialPath, output.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!loadAndVerify(appId, channel))
|
||||
return false;
|
||||
if (!config.setValue("Update", "device_id", m_deviceId))
|
||||
{
|
||||
m_error = QString("cannot save server device id to %1: %2")
|
||||
.arg(config.configPath(), config.lastError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#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;
|
||||
};
|
||||
+7
-62
@@ -1,20 +1,5 @@
|
||||
#include "FileHelper.h"
|
||||
#include <QDebug>
|
||||
#include <QFileInfo>
|
||||
#include <QThread>
|
||||
|
||||
namespace
|
||||
{
|
||||
QString processImageName(const QString& exeName)
|
||||
{
|
||||
QString name = QFileInfo(exeName).fileName().trimmed();
|
||||
#ifndef Q_OS_WIN
|
||||
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
|
||||
name.chop(4);
|
||||
#endif
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
bool FileHelper::createDir(const QString &path)
|
||||
{
|
||||
@@ -30,7 +15,7 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
||||
{
|
||||
if (!QFile::remove(dst))
|
||||
{
|
||||
qDebug() << "Cannot remove old file:" << dst;
|
||||
qDebug() << "Cannot remove the old file:" << dst;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -39,59 +24,19 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
||||
|
||||
bool FileHelper::isProcessRunning(const QString &exeName)
|
||||
{
|
||||
const QString imageName = processImageName(exeName);
|
||||
if (imageName.isEmpty())
|
||||
return false;
|
||||
|
||||
QProcess process;
|
||||
#ifdef Q_OS_WIN
|
||||
process.start(QStringLiteral("tasklist"), QStringList{
|
||||
QStringLiteral("/FI"),
|
||||
QStringLiteral("IMAGENAME eq %1").arg(imageName)
|
||||
});
|
||||
process.start("tasklist");
|
||||
process.waitForFinished();
|
||||
const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
|
||||
return output.contains(imageName, Qt::CaseInsensitive);
|
||||
#elif defined(Q_OS_UNIX)
|
||||
process.start(QStringLiteral("pgrep"), QStringList{
|
||||
QStringLiteral("-x"),
|
||||
imageName
|
||||
});
|
||||
process.waitForFinished(1000);
|
||||
return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
QString output = process.readAllStandardOutput();
|
||||
return output.contains(exeName, Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
bool FileHelper::killProcess(const QString &exeName)
|
||||
{
|
||||
if (!isProcessRunning(exeName))
|
||||
return true;
|
||||
const QString imageName = processImageName(exeName);
|
||||
if (imageName.isEmpty())
|
||||
return true;
|
||||
|
||||
QProcess process;
|
||||
#ifdef Q_OS_WIN
|
||||
process.start(QStringLiteral("taskkill"), QStringList{
|
||||
QStringLiteral("/f"),
|
||||
QStringLiteral("/im"),
|
||||
imageName
|
||||
});
|
||||
#elif defined(Q_OS_UNIX)
|
||||
process.start(QStringLiteral("pkill"), QStringList{
|
||||
QStringLiteral("-x"),
|
||||
imageName
|
||||
});
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
process.waitForFinished(3000);
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
if (!isProcessRunning(imageName))
|
||||
return true;
|
||||
QThread::msleep(100);
|
||||
}
|
||||
return false;
|
||||
process.start("taskkill /f /im " + exeName);
|
||||
process.waitForFinished(1000);
|
||||
return !isProcessRunning(exeName);
|
||||
}
|
||||
|
||||
+36
-103
@@ -4,53 +4,33 @@
|
||||
#include <QFile>
|
||||
#include <QDir>
|
||||
#include <QApplication>
|
||||
#include <QJsonParseError>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
|
||||
namespace {
|
||||
|
||||
int requestTimeoutMs()
|
||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||
{
|
||||
QNetworkAccessManager* manager = new QNetworkAccessManager();
|
||||
manager->setProxy(QNetworkProxy::NoProxy);
|
||||
|
||||
QNetworkRequest req(url);
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
// 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"));
|
||||
if (identity.open(QIODevice::ReadOnly))
|
||||
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
|
||||
|
||||
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
|
||||
qDebug() << "=== POST Request ===";
|
||||
qDebug() << "Url:" << url;
|
||||
qDebug() << "Body:" << data;
|
||||
|
||||
QNetworkReply* reply = manager->post(req, data);
|
||||
QEventLoop loop;
|
||||
bool timeoutOk = false;
|
||||
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
|
||||
if (!timeoutOk || timeoutMs < 1000)
|
||||
timeoutMs = 5000;
|
||||
return timeoutMs;
|
||||
}
|
||||
|
||||
void applyCommonHeaders(QNetworkRequest& req, const QString& bearerToken)
|
||||
{
|
||||
const QString clientToken = ConfigHelper::instance().getValue(QStringLiteral("Server"), QStringLiteral("client_token")).trimmed();
|
||||
if (!clientToken.isEmpty())
|
||||
req.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||
|
||||
const QString token = bearerToken.trimmed();
|
||||
if (!token.isEmpty())
|
||||
req.setRawHeader("Authorization", QByteArray("Bearer ") + token.toUtf8());
|
||||
}
|
||||
|
||||
void readJsonReply(QNetworkReply* reply, int* retCode, QJsonObject* retObj)
|
||||
{
|
||||
*retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray respData = reply->readAll();
|
||||
if (!respData.isEmpty()) {
|
||||
qDebug() << "Server raw response:" << respData;
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(respData, &parseError);
|
||||
if (parseError.error == QJsonParseError::NoError && document.isObject())
|
||||
*retObj = document.object();
|
||||
}
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
qDebug() << "Network error code:" << reply->error();
|
||||
qDebug() << "HTTP status:" << *retCode << "detail:" << reply->errorString();
|
||||
}
|
||||
}
|
||||
|
||||
void waitForReply(const QString& url, QNetworkReply* reply)
|
||||
{
|
||||
QEventLoop loop;
|
||||
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
|
||||
QTimer timer;
|
||||
timer.setSingleShot(true);
|
||||
QObject::connect(&timer, &QTimer::timeout, [&]() {
|
||||
@@ -59,73 +39,26 @@ void waitForReply(const QString& url, QNetworkReply* reply)
|
||||
reply->abort();
|
||||
}
|
||||
});
|
||||
|
||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
timer.start(requestTimeoutMs());
|
||||
timer.start(timeoutMs);
|
||||
loop.exec();
|
||||
timer.stop();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||
{
|
||||
postRequest(url, jsonBody, QString(), callback);
|
||||
}
|
||||
|
||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
const QString& bearerToken,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||
{
|
||||
QNetworkAccessManager* manager = new QNetworkAccessManager();
|
||||
manager->setProxy(QNetworkProxy::NoProxy);
|
||||
|
||||
QNetworkRequest req{QUrl(url)};
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
applyCommonHeaders(req, bearerToken);
|
||||
|
||||
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
|
||||
qDebug() << "=== POST Request ===";
|
||||
qDebug() << "Url:" << url;
|
||||
qDebug() << "Body:" << data;
|
||||
|
||||
QNetworkReply* reply = manager->post(req, data);
|
||||
waitForReply(url, reply);
|
||||
|
||||
int retCode = 0;
|
||||
QJsonObject retObj;
|
||||
readJsonReply(reply, &retCode, &retObj);
|
||||
|
||||
callback(retCode, retObj);
|
||||
|
||||
reply->deleteLater();
|
||||
manager->deleteLater();
|
||||
}
|
||||
|
||||
void HttpHelper::getRequest(const QString& url,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||
{
|
||||
getRequest(url, QString(), callback);
|
||||
}
|
||||
|
||||
void HttpHelper::getRequest(const QString& url, const QString& bearerToken,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||
{
|
||||
QNetworkAccessManager* manager = new QNetworkAccessManager();
|
||||
manager->setProxy(QNetworkProxy::NoProxy);
|
||||
|
||||
QNetworkRequest req{QUrl(url)};
|
||||
applyCommonHeaders(req, bearerToken);
|
||||
|
||||
qDebug() << "=== GET Request ===";
|
||||
qDebug() << "Url:" << url;
|
||||
|
||||
QNetworkReply* reply = manager->get(req);
|
||||
waitForReply(url, reply);
|
||||
|
||||
int retCode = 0;
|
||||
QJsonObject retObj;
|
||||
readJsonReply(reply, &retCode, &retObj);
|
||||
|
||||
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QByteArray respData = reply->readAll();
|
||||
if (!respData.isEmpty()) {
|
||||
qDebug() << "Server raw response:" << respData;
|
||||
retObj = QJsonDocument::fromJson(respData).object();
|
||||
}
|
||||
if (reply->error() != QNetworkReply::NoError)
|
||||
{
|
||||
qDebug() << "Network error code:" << reply->error();
|
||||
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
|
||||
}
|
||||
|
||||
callback(retCode, retObj);
|
||||
|
||||
|
||||
+1
-9
@@ -7,19 +7,11 @@
|
||||
#include <QJsonDocument>
|
||||
#include <QEventLoop>
|
||||
#include <QDebug>
|
||||
#include <functional>
|
||||
|
||||
class HttpHelper
|
||||
{
|
||||
public:
|
||||
// Create an independent manager for each call instead of keeping it as a member.
|
||||
// Each request owns its manager so synchronous calls cannot share reply state.
|
||||
static void postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||
static void postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||
const QString& bearerToken,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||
static void getRequest(const QString& url,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||
static void getRequest(const QString& url, const QString& bearerToken,
|
||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace UpdateClient
|
||||
{
|
||||
inline bool shouldApplyInitialStateValue(const QString& key,
|
||||
bool storedValueExists,
|
||||
const QString& storedValue,
|
||||
const QString& initialValue)
|
||||
{
|
||||
if (!storedValueExists)
|
||||
return true;
|
||||
|
||||
return key == QStringLiteral("license_key")
|
||||
&& storedValue.trimmed().isEmpty()
|
||||
&& !initialValue.trimmed().isEmpty();
|
||||
}
|
||||
}
|
||||
+36
-164
@@ -1,13 +1,12 @@
|
||||
#include "IntegrityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include "UpdatePathPolicy.h"
|
||||
#include "RuntimeProtectionPolicy.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSet>
|
||||
@@ -16,45 +15,26 @@
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
bool configFlag(const QString& key)
|
||||
{
|
||||
const QString value = configValue(key).toLower();
|
||||
return value == QStringLiteral("true")
|
||||
|| value == QStringLiteral("1")
|
||||
|| value == QStringLiteral("yes")
|
||||
|| value == QStringLiteral("on");
|
||||
}
|
||||
|
||||
bool manifestFileRequired(const QJsonObject& item)
|
||||
{
|
||||
if (!item.contains(QStringLiteral("required")))
|
||||
return true;
|
||||
return item.value(QStringLiteral("required")).toBool(true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IntegrityHelper::IntegrityHelper(const QString& installDir)
|
||||
: m_installDir(QDir::cleanPath(installDir)) {}
|
||||
|
||||
QString IntegrityHelper::errorString() const { return m_error; }
|
||||
|
||||
bool IntegrityHelper::isManifestCacheMissing() const
|
||||
{
|
||||
return m_manifestCacheMissing;
|
||||
}
|
||||
|
||||
bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||
{
|
||||
return UpdatePathPolicy::isSafeRelativePath(path);
|
||||
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
|
||||
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
|
||||
&& !clean.startsWith("../") && !clean.contains(":");
|
||||
}
|
||||
|
||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||
{
|
||||
return UpdatePathPolicy::isFullUpdateProtectedPath(
|
||||
return UpdateClient::isRuntimeProtectedPath(
|
||||
path, ConfigHelper::instance().runtimeRelativePath());
|
||||
}
|
||||
|
||||
@@ -70,31 +50,15 @@ QString IntegrityHelper::sha256(const QString& filePath) const
|
||||
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64);
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
|
||||
return false;
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||
#else
|
||||
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
|
||||
if (!QFile::exists(keyPath))
|
||||
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Cannot open manifest public key. Stage: installed version verification. Public key path: %1.")
|
||||
.arg(keyPath);
|
||||
return false;
|
||||
}
|
||||
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "embedded manifest public key missing"; return false; }
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||
if (bio) BIO_free(bio);
|
||||
if (!key) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
|
||||
.arg(keyPath);
|
||||
return false;
|
||||
}
|
||||
if (!key) { m_error = "manifest public key invalid"; 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, key) == 1
|
||||
@@ -102,10 +66,7 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
||||
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
|
||||
if (ctx) EVP_MD_CTX_free(ctx);
|
||||
EVP_PKEY_free(key);
|
||||
if (!ok) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Manifest RSA signature is invalid. Stage: installed version verification. This usually means the cached manifest was changed, the client public key does not match the server private key, or the wrong version cache is being used.");
|
||||
}
|
||||
if (!ok) m_error = "manifest RSA signature invalid";
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
@@ -114,139 +75,61 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
const QString& version)
|
||||
{
|
||||
m_error.clear();
|
||||
// Manifest cache 来自服务端发布版本时生成的签名清单。
|
||||
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
|
||||
m_manifestCacheMissing = false;
|
||||
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
|
||||
"manifest_cache/manifest_" + version + ".json");
|
||||
const QString legacyCachePath = QDir(m_installDir).filePath(
|
||||
"update/manifest_cache/manifest_" + version + ".json");
|
||||
if (!QFile::exists(cachePath))
|
||||
cachePath = legacyCachePath;
|
||||
QFile cache(cachePath);
|
||||
if (!cache.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local signed manifest cache is missing. Stage: installed version verification. Version: %1. Expected cache file: %2. This cache is created after the same version is published or installed successfully.")
|
||||
.arg(version, cachePath);
|
||||
if (!QFile::exists(cachePath)) {
|
||||
m_manifestCacheMissing = true;
|
||||
m_error = "signed manifest cache missing for " + version;
|
||||
return false;
|
||||
}
|
||||
QFile cache(cachePath);
|
||||
if (!cache.open(QIODevice::ReadOnly)) { m_error = "cannot read signed manifest cache for " + version; return false; }
|
||||
QJsonParseError wrapperError;
|
||||
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
|
||||
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local signed manifest cache is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
|
||||
.arg(version, cachePath, wrapperError.errorString());
|
||||
return false;
|
||||
m_error = "manifest cache JSON invalid"; return false;
|
||||
}
|
||||
const QJsonObject wrapper = wrapperDoc.object();
|
||||
QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
|
||||
if (manifestText.isEmpty())
|
||||
manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||
const QString manifestSha256 = wrapper.value("manifestSha256").toString(
|
||||
wrapper.value("manifest_sha256").toString());
|
||||
const QString signature = wrapper.value("signature").toString(
|
||||
wrapper.value("manifest").toObject().value("signature").toString());
|
||||
const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty());
|
||||
if (manifestText.isEmpty()) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
|
||||
.arg(version, cachePath);
|
||||
return false;
|
||||
}
|
||||
if (!manifestSha256.isEmpty()) {
|
||||
const QString actualSha = QString::fromLatin1(
|
||||
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
|
||||
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
|
||||
.arg(version, manifestSha256, actualSha);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (signedManifest && !signature.isEmpty()) {
|
||||
if (!verifySignature(manifestText, signature)) return false;
|
||||
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
|
||||
.arg(version);
|
||||
return false;
|
||||
}
|
||||
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
|
||||
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false;
|
||||
|
||||
QJsonParseError manifestError;
|
||||
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
|
||||
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Signed manifest payload is not valid JSON. Stage: installed version verification. Version: %1. File: %2. JSON error: %3.")
|
||||
.arg(version, cachePath, manifestError.errorString());
|
||||
return false;
|
||||
m_error = "signed manifest payload invalid"; return false;
|
||||
}
|
||||
const QJsonObject manifest = manifestDoc.object();
|
||||
const QString manifestProduct = manifest.value("productCode").toString(
|
||||
manifest.value("app_id").toString());
|
||||
if (manifestProduct != appId
|
||||
if (manifest.value("app_id").toString() != appId
|
||||
|| manifest.value("channel").toString() != channel
|
||||
|| manifest.value("version").toString() != version) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
|
||||
.arg(appId, channel, version,
|
||||
manifestProduct,
|
||||
manifest.value("channel").toString(),
|
||||
manifest.value("version").toString());
|
||||
return false;
|
||||
m_error = "manifest identity does not match local application"; return false;
|
||||
}
|
||||
|
||||
QSet<QString> declaredExecutables;
|
||||
QSet<QString> optionalComponentDirs;
|
||||
for (const QJsonValue& value : manifest.value("files").toArray()) {
|
||||
const QJsonObject item = value.toObject();
|
||||
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||
if (!safeRelativePath(path)) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Signed manifest contains an unsafe file path. Stage: installed version verification. Version: %1. Path: %2.")
|
||||
.arg(version, path);
|
||||
return false;
|
||||
}
|
||||
if (UpdatePathPolicy::isExecutableOrLibrary(path))
|
||||
declaredExecutables.insert(path.toCaseFolded());
|
||||
if (!manifestFileRequired(item)) {
|
||||
const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path());
|
||||
if (!dir.isEmpty() && dir != QStringLiteral("."))
|
||||
optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded());
|
||||
continue;
|
||||
}
|
||||
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; }
|
||||
if (runtimeProtectedPath(path)) continue;
|
||||
const QString fullPath = QDir(m_installDir).filePath(path);
|
||||
if (!QFile::exists(fullPath)) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"A required installed file is missing. Stage: installed version verification. Version: %1. Manifest path: %2. Checked path: %3. The local installation no longer matches the published version.")
|
||||
.arg(version, path, fullPath);
|
||||
return false;
|
||||
}
|
||||
const qint64 expectedSize = item.contains("sizeBytes")
|
||||
? item.value("sizeBytes").toVariant().toLongLong()
|
||||
: item.value("size").toVariant().toLongLong();
|
||||
if ((item.contains("sizeBytes") || item.contains("size"))
|
||||
&& QFileInfo(fullPath).size() != expectedSize) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes")
|
||||
.arg(version, path, fullPath,
|
||||
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
|
||||
return false;
|
||||
}
|
||||
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; }
|
||||
const QString expected = item.value("sha256").toString();
|
||||
const QString actual = sha256(fullPath);
|
||||
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Installed file SHA-256 does not match the local signed manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected SHA-256: %4\nActual SHA-256: %5\nThis means the installed file is different from the version that was published or installed. If this is a developer test machine, check whether the local Release directory was recompiled or overwritten after publishing.")
|
||||
.arg(version, path, fullPath, expected,
|
||||
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
|
||||
return false;
|
||||
m_error = "file hash mismatch: " + path; return false;
|
||||
}
|
||||
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
|
||||
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -257,20 +140,9 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
|| folded.startsWith(runtimePrefix + "/update_temp/"));
|
||||
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
||||
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
||||
bool optionalComponentFile = false;
|
||||
for (const QString& prefix : optionalComponentDirs) {
|
||||
if (folded.startsWith(prefix)) {
|
||||
optionalComponentFile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (optionalComponentFile)
|
||||
continue;
|
||||
if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
|
||||
.arg(version, relative);
|
||||
return false;
|
||||
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
|
||||
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
|
||||
m_error = "undeclared executable or plugin: " + relative; return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -7,6 +7,7 @@ public:
|
||||
explicit IntegrityHelper(const QString& installDir);
|
||||
bool verifyInstalledVersion(const QString& appId, const QString& channel,
|
||||
const QString& version);
|
||||
bool isManifestCacheMissing() const;
|
||||
QString errorString() const;
|
||||
|
||||
private:
|
||||
@@ -17,4 +18,5 @@ private:
|
||||
|
||||
QString m_installDir;
|
||||
QString m_error;
|
||||
bool m_manifestCacheMissing = false;
|
||||
};
|
||||
|
||||
+20
-36
@@ -1,28 +1,28 @@
|
||||
#include "LocalStateHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QDir>
|
||||
|
||||
LocalStateHelper::LocalStateHelper(const QString& baseDir)
|
||||
: m_baseDir(baseDir)
|
||||
, m_filePath(ConfigHelper::instance().localStatePath())
|
||||
, m_filePath(baseDir + "/config/local_state.json")
|
||||
{
|
||||
}
|
||||
|
||||
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;
|
||||
m_filePath = m_baseDir + "/" + relativePath;
|
||||
QFile file(m_filePath);
|
||||
if (!file.exists())
|
||||
{
|
||||
QDir dir(QFileInfo(m_filePath).path());
|
||||
if (!dir.exists() && !dir.mkpath("."))
|
||||
{
|
||||
m_error = QString("Cannot create state directory: %1").arg(dir.path());
|
||||
return false;
|
||||
}
|
||||
|
||||
m_state = QJsonObject{
|
||||
{"max_policy_seq", 0},
|
||||
{"last_success_run_at", QString()},
|
||||
@@ -32,10 +32,7 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
m_loaded = true;
|
||||
if (!saveState())
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Cannot create local state file: %1. Error: %2.")
|
||||
.arg(m_filePath, m_error);
|
||||
m_error = QString("Cannot write new state file: %1").arg(m_filePath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -43,10 +40,7 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Cannot open local state file: %1. Error: %2.")
|
||||
.arg(m_filePath, file.errorString());
|
||||
m_error = QString("Cannot open state file: %1").arg(m_filePath);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,10 +51,7 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Local state file is not valid JSON. File: %1. JSON error: %2.")
|
||||
.arg(m_filePath, parseError.errorString());
|
||||
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -72,24 +63,17 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
||||
bool LocalStateHelper::saveState() const
|
||||
{
|
||||
if (!m_loaded)
|
||||
return false;
|
||||
|
||||
QFile file(m_filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Local state has not been loaded.");
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonDocument doc(m_state);
|
||||
QString writeError;
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(m_filePath, doc.toJson(QJsonDocument::Indented), &writeError))
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"LocalStateHelper",
|
||||
"Cannot save local state file: %1. Error: %2.")
|
||||
.arg(m_filePath, writeError);
|
||||
return false;
|
||||
}
|
||||
m_error.clear();
|
||||
file.write(doc.toJson(QJsonDocument::Indented));
|
||||
file.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public:
|
||||
private:
|
||||
QString m_baseDir;
|
||||
QString m_filePath;
|
||||
mutable QString m_error;
|
||||
QString m_error;
|
||||
QJsonObject m_state;
|
||||
bool m_loaded = false;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
|
||||
namespace UpdateClient
|
||||
{
|
||||
enum class ManifestBootstrapAction
|
||||
{
|
||||
None,
|
||||
FetchCurrentManifest,
|
||||
CurrentVersionNotPublished
|
||||
};
|
||||
|
||||
inline ManifestBootstrapAction manifestBootstrapAction(
|
||||
bool manifestCacheMissing,
|
||||
bool networkAvailable,
|
||||
bool updateAvailable,
|
||||
int serverVersionId)
|
||||
{
|
||||
if (!manifestCacheMissing || !networkAvailable || updateAvailable)
|
||||
return ManifestBootstrapAction::None;
|
||||
if (serverVersionId <= 0)
|
||||
return ManifestBootstrapAction::CurrentVersionNotPublished;
|
||||
return ManifestBootstrapAction::FetchCurrentManifest;
|
||||
}
|
||||
}
|
||||
+58
-91
@@ -1,12 +1,10 @@
|
||||
#include "PolicyHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <algorithm>
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
@@ -15,62 +13,58 @@
|
||||
|
||||
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)
|
||||
{
|
||||
const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Cannot open signed version policy file: %1. Error: %2.")
|
||||
.arg(path, file.errorString());
|
||||
return false;
|
||||
}
|
||||
QFile file(m_baseDir + "/" + relativePath);
|
||||
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()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
|
||||
.arg(path, error.errorString());
|
||||
return false;
|
||||
m_error = "Invalid policy JSON: " + error.errorString(); return false;
|
||||
}
|
||||
return loadPolicyObject(doc.object());
|
||||
const QJsonObject stored = doc.object();
|
||||
if (stored.value("policy").isObject() && stored.value("policy_text").isString())
|
||||
return loadPolicyObject(stored.value("policy").toObject(),
|
||||
stored.value("policy_text").toString());
|
||||
return loadPolicyObject(stored);
|
||||
}
|
||||
|
||||
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
|
||||
{
|
||||
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
|
||||
m_error.clear();
|
||||
m_signedText = signedText;
|
||||
m_policy = policy;
|
||||
if (!signedText.isEmpty())
|
||||
{
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument signedDocument =
|
||||
QJsonDocument::fromJson(signedText.toUtf8(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !signedDocument.isObject())
|
||||
{
|
||||
m_error = "Signed policy text is invalid JSON: " + parseError.errorString();
|
||||
m_loaded = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString signature = policy.value("signature").toString();
|
||||
m_policy = signedDocument.object();
|
||||
m_policy.insert("signature", signature);
|
||||
}
|
||||
m_loaded = true;
|
||||
return isValid();
|
||||
}
|
||||
|
||||
bool PolicyHelper::savePolicy(const QString& relativePath) const
|
||||
{
|
||||
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
|
||||
QString writeError;
|
||||
const QString path = resolvePolicyPath(m_baseDir, relativePath, true);
|
||||
if (!ConfigHelper::writeFileWithElevationIfNeeded(path, bytes, &writeError)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Cannot save signed version policy file: %1. Error: %2.")
|
||||
.arg(path, writeError);
|
||||
return false;
|
||||
QSaveFile file(m_baseDir + "/" + relativePath);
|
||||
if (!file.open(QIODevice::WriteOnly)) return false;
|
||||
QJsonObject stored = m_policy;
|
||||
if (!m_signedText.isEmpty())
|
||||
{
|
||||
stored = QJsonObject{{"policy", m_policy}, {"policy_text", m_signedText}};
|
||||
}
|
||||
m_error.clear();
|
||||
return true;
|
||||
const QByteArray bytes = QJsonDocument(stored).toJson(QJsonDocument::Indented);
|
||||
return file.write(bytes) == bytes.size() && file.commit();
|
||||
}
|
||||
|
||||
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
||||
@@ -100,75 +94,49 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
||||
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
|
||||
{
|
||||
#ifndef HAVE_OPENSSL
|
||||
Q_UNUSED(payload);
|
||||
Q_UNUSED(signatureBase64);
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
|
||||
return false;
|
||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
||||
#else
|
||||
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
|
||||
QFile keyFile(keyPath);
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Cannot open version policy public key: %1. Error: %2.")
|
||||
.arg(keyPath, keyFile.errorString());
|
||||
return false;
|
||||
}
|
||||
QFile keyFile(QStringLiteral(":/simcae/update-client/manifest-public-key.pem"));
|
||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open embedded policy public key"; return false; }
|
||||
const QByteArray keyData = keyFile.readAll();
|
||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||
if (bio) BIO_free(bio);
|
||||
if (!key) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy public key is invalid: %1.")
|
||||
.arg(keyPath);
|
||||
return false;
|
||||
}
|
||||
if (!key) { m_error = "Invalid policy public key"; return false; }
|
||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 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(key);
|
||||
if (!ok) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy RSA signature is invalid. The policy file may have been changed, or the public key does not match the server private key.");
|
||||
}
|
||||
if (!ok) m_error = "Invalid RSA policy signature";
|
||||
return ok;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool PolicyHelper::isValid() const
|
||||
{
|
||||
if (!m_loaded) {
|
||||
m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
|
||||
return false;
|
||||
}
|
||||
if (!m_loaded) return false;
|
||||
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
|
||||
"force_update","allow_rollback","offline_allowed","valid_until","signature_alg","key_id","signature"};
|
||||
for (const QString& key : required) {
|
||||
if (!m_policy.contains(key)) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy is missing required field: %1.")
|
||||
.arg(key);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") {
|
||||
m_error = QCoreApplication::translate(
|
||||
"PolicyHelper",
|
||||
"Version policy signature algorithm is unsupported: %1.")
|
||||
.arg(m_policy.value("signature_alg").toString());
|
||||
return false;
|
||||
}
|
||||
for (const QString& key : required) if (!m_policy.contains(key)) { m_error = "Missing policy field: " + key; return false; }
|
||||
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
|
||||
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
|
||||
return verifySignature(payload, m_policy.value("signature").toString());
|
||||
}
|
||||
bool PolicyHelper::isApplicable(const QString& appId, const QString& channel,
|
||||
const QString& currentVersion) const
|
||||
{
|
||||
if (!isValid()) return false;
|
||||
if (m_policy.value("app_id").toString() != appId
|
||||
|| m_policy.value("channel").toString() != channel
|
||||
|| m_policy.value("current_version").toString() != currentVersion)
|
||||
{
|
||||
m_error = "Policy identity does not match this installation";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
QString PolicyHelper::errorString() const { return m_error; }
|
||||
bool PolicyHelper::isVersionAllowed(const QString& version) const {
|
||||
if (!isValid()) return false;
|
||||
@@ -179,7 +147,6 @@ bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_
|
||||
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
|
||||
bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
|
||||
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); }
|
||||
bool PolicyHelper::gitTagsEnabled() const { return isValid() && m_policy.value("git_tags_enabled").toBool(); }
|
||||
bool PolicyHelper::isExpired() const {
|
||||
if (!isValid()) return true;
|
||||
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
|
||||
|
||||
@@ -11,13 +11,14 @@ public:
|
||||
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
|
||||
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
|
||||
bool isValid() const;
|
||||
bool isApplicable(const QString& appId, const QString& channel,
|
||||
const QString& currentVersion) const;
|
||||
QString errorString() const;
|
||||
bool isVersionAllowed(const QString& currentVersion) const;
|
||||
bool allowRun() const;
|
||||
bool forceUpdate() const;
|
||||
bool allowRollback() const;
|
||||
bool isOfflineAllowed() const;
|
||||
bool gitTagsEnabled() const;
|
||||
bool isExpired() const;
|
||||
qint64 policySeq() const;
|
||||
QString message() const;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma once
|
||||
|
||||
#include <QDir>
|
||||
#include <QSet>
|
||||
#include <QString>
|
||||
|
||||
namespace UpdateClient
|
||||
{
|
||||
inline bool isRuntimeProtectedPath(const QString& path,
|
||||
const QString& runtimeRelativePath)
|
||||
{
|
||||
const QString normalizedPath =
|
||||
QDir::cleanPath(QDir::fromNativeSeparators(path)).toCaseFolded();
|
||||
static const QSet<QString> installerManagedRootFiles{
|
||||
QStringLiteral("components.xml"),
|
||||
QStringLiteral("installationlog.txt"),
|
||||
QStringLiteral("installer.dat"),
|
||||
QStringLiteral("maintenancetool.dat"),
|
||||
QStringLiteral("maintenancetool.exe"),
|
||||
QStringLiteral("maintenancetool.ini"),
|
||||
QStringLiteral("network.xml")
|
||||
};
|
||||
if (installerManagedRootFiles.contains(normalizedPath)
|
||||
|| normalizedPath.startsWith(QStringLiteral("installerresources/"))
|
||||
|| normalizedPath.startsWith(QStringLiteral("licenses/")))
|
||||
return true;
|
||||
|
||||
static const QSet<QString> runtimeProtectedPaths{
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("client.ini"),
|
||||
QStringLiteral("config/app_config.json"),
|
||||
QStringLiteral("config/local_state.json"),
|
||||
QStringLiteral("config/client_identity.dat"),
|
||||
QStringLiteral("config/version_policy.dat")
|
||||
};
|
||||
if (runtimeProtectedPaths.contains(normalizedPath))
|
||||
return true;
|
||||
|
||||
QString runtimePrefix = QDir::cleanPath(
|
||||
QDir::fromNativeSeparators(runtimeRelativePath)).toCaseFolded();
|
||||
if (runtimePrefix.isEmpty() || runtimePrefix == QStringLiteral("."))
|
||||
return false;
|
||||
while (runtimePrefix.startsWith(QStringLiteral("./")))
|
||||
runtimePrefix.remove(0, 2);
|
||||
|
||||
for (const QString& protectedPath : runtimeProtectedPaths) {
|
||||
if (normalizedPath == runtimePrefix + QLatin1Char('/') + protectedPath)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+67
-104
@@ -3,12 +3,12 @@
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QSaveFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QUuid>
|
||||
|
||||
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
@@ -17,24 +17,58 @@ static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
static bool constantTimeEqual(const QByteArray& left, const QByteArray& right)
|
||||
{
|
||||
if (left.size() != right.size()) return false;
|
||||
unsigned char difference = 0;
|
||||
for (qsizetype index = 0; index < left.size(); ++index)
|
||||
difference |= static_cast<unsigned char>(left.at(index) ^ right.at(index));
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool claimTicketNonce(const QString& appId, const QString& nonce, QString* errorMessage)
|
||||
{
|
||||
const QString claimsRoot = QDir(QStandardPaths::writableLocation(
|
||||
QStandardPaths::AppLocalDataLocation)).filePath("launcher-ticket-claims");
|
||||
if (!QDir().mkpath(claimsRoot)) {
|
||||
if (errorMessage) *errorMessage = "cannot create ticket replay state";
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir claimsDirectory(claimsRoot);
|
||||
const QDateTime staleBefore = QDateTime::currentDateTimeUtc().addSecs(-600);
|
||||
const QFileInfoList oldClaims = claimsDirectory.entryInfoList(
|
||||
QStringList{"*.claimed"}, QDir::Files);
|
||||
for (const QFileInfo& claim : oldClaims)
|
||||
if (claim.lastModified().toUTC() < staleBefore) QFile::remove(claim.absoluteFilePath());
|
||||
|
||||
const QByteArray claimIdentity = appId.toUtf8() + '\0' + nonce.toUtf8();
|
||||
const QString claimName = QString::fromLatin1(
|
||||
QCryptographicHash::hash(claimIdentity, QCryptographicHash::Sha256).toHex()) + ".claimed";
|
||||
QFile claimFile(claimsDirectory.filePath(claimName));
|
||||
if (!claimFile.open(QIODevice::WriteOnly | QIODevice::NewOnly)) {
|
||||
if (errorMessage) *errorMessage = "ticket nonce was already consumed";
|
||||
return false;
|
||||
}
|
||||
const QByteArray marker = QDateTime::currentDateTimeUtc().toString(Qt::ISODate).toUtf8();
|
||||
if (claimFile.write(marker) != marker.size()) {
|
||||
const QString claimPath = claimFile.fileName();
|
||||
claimFile.close();
|
||||
QFile::remove(claimPath);
|
||||
if (errorMessage) *errorMessage = "cannot persist ticket replay state";
|
||||
return false;
|
||||
}
|
||||
claimFile.close();
|
||||
QFile::setPermissions(claimFile.fileName(), QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
return true;
|
||||
}
|
||||
|
||||
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() || deviceId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) {
|
||||
QStringList missing;
|
||||
if (appId.isEmpty()) missing.append(QStringLiteral("app_id"));
|
||||
if (deviceId.isEmpty()) missing.append(QStringLiteral("device_id"));
|
||||
if (version.isEmpty()) missing.append(QStringLiteral("current_version"));
|
||||
if (secret.isEmpty()) missing.append(QStringLiteral("launch_token"));
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot create launch ticket because required fields are empty: %1. Check app_config.json, registry-imported configuration and device authorization.")
|
||||
.arg(missing.join(QStringLiteral(", ")));
|
||||
}
|
||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
||||
return false;
|
||||
}
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
@@ -47,25 +81,12 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
||||
};
|
||||
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
|
||||
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
|
||||
if (!QDir().mkpath(dirPath)) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot create launch ticket directory: %1.")
|
||||
.arg(dirPath);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
|
||||
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
|
||||
QSaveFile file(path);
|
||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot save launch ticket file: %1. Error: %2.")
|
||||
.arg(path, file.errorString());
|
||||
}
|
||||
if (errorMessage) *errorMessage = "cannot save ticket";
|
||||
return false;
|
||||
}
|
||||
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
@@ -77,42 +98,29 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||
const QString& secret, QString* errorMessage)
|
||||
{
|
||||
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
|
||||
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
|
||||
if (expectedAppId.isEmpty() || expectedDeviceId.isEmpty()
|
||||
|| expectedVersion.isEmpty() || secret.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "expected ticket identity or secret is incomplete";
|
||||
return false;
|
||||
}
|
||||
const QString consumingPath = ticketPath + ".consuming."
|
||||
+ QString::number(QCoreApplication::applicationPid());
|
||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket is missing or has already been consumed. Ticket file: %1. Please start the application from Launcher.")
|
||||
.arg(ticketPath);
|
||||
}
|
||||
if (errorMessage) *errorMessage = "ticket missing or already consumed";
|
||||
return false;
|
||||
}
|
||||
QFile file(consumingPath);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
QFile::remove(consumingPath);
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Cannot read claimed launch ticket: %1. Error: %2.")
|
||||
.arg(consumingPath, file.errorString());
|
||||
}
|
||||
if (errorMessage) *errorMessage = "cannot read claimed ticket";
|
||||
return false;
|
||||
}
|
||||
const QByteArray raw = file.readAll(); file.close();
|
||||
QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure.
|
||||
QFile::remove(consumingPath); // Consume once so neither successful nor failed claims can be replayed.
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket is not valid JSON. JSON error: %1.")
|
||||
.arg(parseError.errorString());
|
||||
}
|
||||
return false;
|
||||
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false;
|
||||
}
|
||||
const QJsonObject wrapper = doc.object();
|
||||
const QJsonObject payload = wrapper.value("payload").toObject();
|
||||
@@ -121,61 +129,16 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
||||
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
|
||||
const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
const bool identityOk = payload.value("app_id").toString() == expectedAppId
|
||||
&& payload.value("device_id").toString() == expectedDeviceId
|
||||
&& payload.value("version").toString() == expectedVersion;
|
||||
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||
if (actual.isEmpty() || actual != expected) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket signature is invalid. The launch_token used by Launcher and the main application is inconsistent, or the ticket content was changed.");
|
||||
}
|
||||
const QString nonce = payload.value("nonce").toString();
|
||||
if (actual.isEmpty() || !constantTimeEqual(actual, expected) || !identityOk || !timeOk
|
||||
|| nonce.isEmpty()) {
|
||||
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
|
||||
return false;
|
||||
}
|
||||
if (payload.value("app_id").toString() != expectedAppId) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket app_id does not match. Ticket app_id: %1. Expected app_id: %2.")
|
||||
.arg(payload.value("app_id").toString(), expectedAppId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("device_id").toString() != expectedDeviceId) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket device_id does not match. Ticket device_id: %1. Expected device_id: %2. Reauthorize the device from Launcher if the configuration was regenerated.")
|
||||
.arg(payload.value("device_id").toString(), expectedDeviceId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("version").toString() != expectedVersion) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket version does not match. Ticket version: %1. Expected version: %2.")
|
||||
.arg(payload.value("version").toString(), expectedVersion);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!timeOk) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket time is invalid or expired. Issued at: %1. Expires at: %2. Current UTC time: %3.")
|
||||
.arg(payload.value("issued_at").toString(),
|
||||
payload.value("expires_at").toString(),
|
||||
now.toString(Qt::ISODate));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (payload.value("nonce").toString().isEmpty()) {
|
||||
if (errorMessage) {
|
||||
*errorMessage = QCoreApplication::translate(
|
||||
"TicketHelper",
|
||||
"Launch ticket nonce is missing. The ticket is incomplete.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return claimTicketNonce(expectedAppId, nonce, errorMessage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#include "TranslationHelper.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr auto kSimplifiedChineseCatalog =
|
||||
":/simcae/update-client/i18n/update-client_zh_CN.qm";
|
||||
constexpr auto kQtSimplifiedChineseCatalog = "translations/qt_zh_CN.qm";
|
||||
|
||||
bool usesSimplifiedChinese(const QLocale& locale)
|
||||
{
|
||||
if (locale.language() != QLocale::Chinese ||
|
||||
locale.script() == QLocale::TraditionalHanScript)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return locale.script() == QLocale::SimplifiedHanScript ||
|
||||
locale.country() == QLocale::China ||
|
||||
locale.country() == QLocale::Singapore;
|
||||
}
|
||||
}
|
||||
|
||||
TranslationHelper::TranslationHelper()
|
||||
{
|
||||
m_simplifiedChineseCatalog.load(
|
||||
QString::fromLatin1(kSimplifiedChineseCatalog));
|
||||
}
|
||||
|
||||
bool TranslationHelper::installForSystemLocale()
|
||||
{
|
||||
if (!usesSimplifiedChinese(QLocale::system()) ||
|
||||
m_simplifiedChineseCatalog.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString qtCatalogPath = QDir(QCoreApplication::applicationDirPath())
|
||||
.filePath(QString::fromLatin1(
|
||||
kQtSimplifiedChineseCatalog));
|
||||
if (m_qtSimplifiedChineseCatalog.load(qtCatalogPath))
|
||||
QCoreApplication::installTranslator(&m_qtSimplifiedChineseCatalog);
|
||||
|
||||
return QCoreApplication::installTranslator(&m_simplifiedChineseCatalog);
|
||||
}
|
||||
|
||||
QString TranslationHelper::translate(const char* context,
|
||||
const char* sourceText) const
|
||||
{
|
||||
return m_simplifiedChineseCatalog.translate(context, sourceText);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <QLocale>
|
||||
#include <QString>
|
||||
#include <QTranslator>
|
||||
|
||||
class TranslationHelper final
|
||||
{
|
||||
public:
|
||||
TranslationHelper();
|
||||
|
||||
bool installForSystemLocale();
|
||||
QString translate(const char* context, const char* sourceText) const;
|
||||
|
||||
private:
|
||||
QTranslator m_qtSimplifiedChineseCatalog;
|
||||
QTranslator m_simplifiedChineseCatalog;
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
#include "UpdatePathPolicy.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
|
||||
namespace {
|
||||
|
||||
bool exactOrRuntimeMatch(const QString& folded, const QString& runtimePrefix,
|
||||
const QSet<QString>& exactPaths)
|
||||
{
|
||||
if (exactPaths.contains(folded))
|
||||
return true;
|
||||
if (runtimePrefix.isEmpty() || !folded.startsWith(runtimePrefix + QStringLiteral("/")))
|
||||
return false;
|
||||
return exactPaths.contains(folded.mid(runtimePrefix.size() + 1));
|
||||
}
|
||||
|
||||
bool prefixOrRuntimePrefixMatch(const QString& folded, const QString& runtimePrefix,
|
||||
const QString& prefix)
|
||||
{
|
||||
if (folded.startsWith(prefix))
|
||||
return true;
|
||||
if (runtimePrefix.isEmpty())
|
||||
return false;
|
||||
return folded.startsWith(runtimePrefix + QStringLiteral("/") + prefix);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace UpdatePathPolicy {
|
||||
|
||||
QString normalizeRelativePath(const QString& path)
|
||||
{
|
||||
QString normalized = QDir::cleanPath(QDir::fromNativeSeparators(path.trimmed()));
|
||||
if (normalized == QStringLiteral("."))
|
||||
return QString();
|
||||
while (normalized.startsWith(QStringLiteral("./")))
|
||||
normalized = normalized.mid(2);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
bool isSafeRelativePath(const QString& path)
|
||||
{
|
||||
const QString clean = normalizeRelativePath(path);
|
||||
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != QStringLiteral("..")
|
||||
&& !clean.startsWith(QStringLiteral("../")) && !clean.contains(QLatin1Char(':'));
|
||||
}
|
||||
|
||||
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath)
|
||||
{
|
||||
const QString folded = normalizeRelativePath(path).toCaseFolded();
|
||||
const QString runtimePrefix = normalizeRelativePath(runtimeRelativePath).toCaseFolded();
|
||||
const QSet<QString> exactPaths{
|
||||
QStringLiteral("bootstrap"),
|
||||
QStringLiteral("bootstrap.exe"),
|
||||
QStringLiteral("launcher"),
|
||||
QStringLiteral("launcher.exe"),
|
||||
QStringLiteral("updater"),
|
||||
QStringLiteral("updater.exe"),
|
||||
QStringLiteral("client.ini"),
|
||||
QStringLiteral("config/app_config.json"),
|
||||
QStringLiteral("config/local_state.json"),
|
||||
QStringLiteral("config/client_identity.dat"),
|
||||
QStringLiteral("config/version_policy.dat")
|
||||
};
|
||||
if (exactOrRuntimeMatch(folded, runtimePrefix, exactPaths))
|
||||
return true;
|
||||
return prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update/"))
|
||||
|| prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update_temp/"));
|
||||
}
|
||||
|
||||
bool isIFWInstallerManagedPath(const QString& path)
|
||||
{
|
||||
const QString folded = normalizeRelativePath(path).toCaseFolded();
|
||||
if (folded.isEmpty())
|
||||
return false;
|
||||
|
||||
const bool rootFile = !folded.contains(QLatin1Char('/'));
|
||||
if (rootFile && (folded == QStringLiteral("maintenancetool")
|
||||
|| folded == QStringLiteral("maintenancetool.exe")
|
||||
|| folded.startsWith(QStringLiteral("maintenancetool.")))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const QSet<QString> exactPaths{
|
||||
QStringLiteral("components.xml"),
|
||||
QStringLiteral("components.xml.new"),
|
||||
QStringLiteral("components.xml.old"),
|
||||
QStringLiteral("installation.xml"),
|
||||
QStringLiteral("installation.dat"),
|
||||
QStringLiteral("installer.dat"),
|
||||
QStringLiteral("installer.ini"),
|
||||
QStringLiteral("network.xml"),
|
||||
QStringLiteral("repositories.xml"),
|
||||
QStringLiteral("repositories.cfg"),
|
||||
QStringLiteral("repository.xml")
|
||||
};
|
||||
if (exactPaths.contains(folded))
|
||||
return true;
|
||||
|
||||
const QStringList prefixes{
|
||||
QStringLiteral("installerresources/"),
|
||||
QStringLiteral("installationinformation/"),
|
||||
QStringLiteral("licenses/")
|
||||
};
|
||||
for (const QString& prefix : prefixes) {
|
||||
if (folded.startsWith(prefix))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath)
|
||||
{
|
||||
return isUpdaterRuntimeProtectedPath(path, runtimeRelativePath)
|
||||
|| isIFWInstallerManagedPath(path);
|
||||
}
|
||||
|
||||
bool isExecutableOrLibrary(const QString& path)
|
||||
{
|
||||
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
|
||||
return suffix == QStringLiteral("exe") || suffix == QStringLiteral("dll");
|
||||
}
|
||||
|
||||
} // namespace UpdatePathPolicy
|
||||
@@ -1,14 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace UpdatePathPolicy {
|
||||
|
||||
QString normalizeRelativePath(const QString& path);
|
||||
bool isSafeRelativePath(const QString& path);
|
||||
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath);
|
||||
bool isIFWInstallerManagedPath(const QString& path);
|
||||
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath);
|
||||
bool isExecutableOrLibrary(const QString& path);
|
||||
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
SimCAE Hub 客户端文档入口
|
||||
========================
|
||||
|
||||
本目录记录 SimCAE Hub 的 Qt/C++ 客户端更新链路。当前方向是保留 Launcher / Updater / Bootstrap 的桌面客户端机制,适配 SimCAE Hub 当前 Go API,不用 Go 或 Web 技术重写客户端。
|
||||
|
||||
建议先按这个顺序阅读:
|
||||
|
||||
1. 01-客户端接入打包部署指南.md
|
||||
说明客户端运行链路、配置字段、安装目录口径和接入限制。
|
||||
|
||||
2. 02-编译环境和第三方依赖说明.md
|
||||
说明 Windows 和 Linux 下编译 Qt/C++ 客户端需要的工具、Qt、OpenSSL 和 CMake 命令。
|
||||
|
||||
3. ../打包成SDK.md
|
||||
说明如何从 update-client 编译产物生成给 SIMCAE 开发者使用的 SDK 包。
|
||||
|
||||
4. ../config/server_config.json
|
||||
编译进客户端资源的服务端地址配置,当前测试服务器是 http://192.168.1.158:18000。
|
||||
|
||||
5. ../scripts/ReadMe.txt
|
||||
SDK 打包脚本和客户安装包打包脚本的简短说明。
|
||||
|
||||
客户端能力、接口链路、配置字段和接入限制统一看 01 文档。当前不包含邮箱、支付、告警、灰度发布等页面上没有的业务模块;崩溃报告作为旧系统兼容后端接口保留,具体看项目根目录的 项目细节.md。
|
||||
@@ -1,210 +0,0 @@
|
||||
# SimCAE Hub 客户端接入、打包和部署指南
|
||||
|
||||
本文说明 `update-client` 的当前实现。它保留 Launcher / Updater / Bootstrap 的桌面客户端机制,服务端协议使用 SimCAE Hub 当前 Go API。
|
||||
|
||||
## 1. 适用范围
|
||||
|
||||
当前客户端只覆盖项目已有页面和接口对应的能力:
|
||||
|
||||
1. 产品版本、软件发布、发布包和 Manifest。
|
||||
2. 客户授权、在线命名用户席位和门户受控下载。
|
||||
3. 在线检查更新、Manifest 拉取、受控下载、SHA-256 校验。
|
||||
4. Manifest 签名验签、临时文件、断点重试、安装前后完整性校验。
|
||||
|
||||
邮箱、支付、灰度、告警等页面上没有的能力不属于当前范围。崩溃报告是 SimCAE Hub 保留的旧系统兼容后端接口,不属于 Launcher / Updater / Bootstrap 的更新链路;接入方需要崩溃上报时,按项目根目录 `项目细节.md` 里的崩溃报告接口说明调用。
|
||||
|
||||
## 2. 客户端程序组成
|
||||
|
||||
| 程序 | 作用 |
|
||||
| --- | --- |
|
||||
| `Launcher` | 客户日常启动入口,负责导入配置、使用 `client_token` 检查更新、启动 Updater 或主程序 |
|
||||
| `Updater` | 负责拉取 Manifest、下载发布包、校验文件、准备安装事务 |
|
||||
| `Bootstrap` | 负责在需要替换运行中文件时接管安装,并把结果交回 Updater |
|
||||
| `MainApp` | 示例主程序,用来验证 launch ticket 和安装后完整性校验 |
|
||||
| `Common` | 配置、HTTP、票据、完整性校验等公共代码 |
|
||||
|
||||
## 3. 当前在线更新链路
|
||||
|
||||
1. `Launcher` 启动后读取服务端生成的 `config/app_config.json`,并把静态配置导入当前用户的运行配置。
|
||||
2. 如果配置里没有 `api_base_url`,客户端会回退到编译进 EXE 资源中的 `server_config.json`。
|
||||
3. `Launcher` 确保存在 `device_id`,并检查 `client_token` 是否存在。
|
||||
4. `Launcher` 调用 `GET /api/v1/client/update/authorized-check`,请求头带 `X-Client-Token`。
|
||||
5. 如果服务端返回可用发布,`Launcher` 启动 `Updater`,并传入产品编码、渠道、目标版本和发布 ID。
|
||||
6. `Updater` 调用 `GET /api/v1/client/update/manifest`,请求头继续带 `X-Client-Token`。
|
||||
7. `Updater` 先校验服务端返回的 `manifestSha256`,再按配置决定是否强制要求 RSA-SHA256 签名。
|
||||
8. `Updater` 从 Manifest 中读取每个文件的 `downloadUrl`、`sizeBytes` 和 `sha256`。
|
||||
9. 下载请求统一带 `X-Client-Token`。
|
||||
10. 下载使用 `.part` 临时文件保存进度,请求失败后按网络重试策略处理。
|
||||
11. 文件下载完成后,客户端按 Manifest 校验文件大小和 SHA-256。
|
||||
12. 安装前校验 staging 目录,安装完成后保存 Manifest 缓存,并可在主程序启动时再次校验已安装文件。
|
||||
|
||||
## 4. 关键配置字段
|
||||
|
||||
正式客户安装包里的 `config/app_config.json` 由服务端在上传发布包 ZIP 时自动生成。常用字段如下:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `product_code` | SimCAE Hub 后台产品目录中的产品编码,例如 `stage2-dap` |
|
||||
| `app_id` | 本地应用标识,默认和产品编码一致 |
|
||||
| `channel` | 发布渠道,例如 `stable` |
|
||||
| `current_version` | 当前本地安装版本,例如 `1.0.0` |
|
||||
| `api_base_url` | 后端 API 地址,例如 `http://192.168.1.158:18000` |
|
||||
| `client_token` | Launcher/Updater 调更新接口使用的部署级令牌,不绑定某一个客户 |
|
||||
| `install_root` | 相对 Launcher/Updater 所在运行目录解析的更新根目录,决定 Updater、Bootstrap 和启动校验作用在哪棵目录 |
|
||||
| `main_executable` | 相对运行目录解析的业务入口程序,通常是 `MainApp.exe` 或真实软件入口 |
|
||||
| `launcher_executable` | 相对运行目录解析的 Launcher 文件名,主要用于提示和保持启动链路配置一致 |
|
||||
| `updater_executable` | 相对运行目录解析的 Updater 文件名,Launcher 检查到更新后会启动它 |
|
||||
| `bootstrap_executable` | 相对运行目录解析的 Bootstrap 文件名,Updater 需要替换文件时会启动它 |
|
||||
| `platform` | 操作系统,例如 `windows` 或 `linux` |
|
||||
| `arch` | 架构,例如 `x86_64` |
|
||||
| `abi` | ABI,例如 `msvc`;没有时可留空 |
|
||||
| `launch_token` | Launcher 和 MainApp 之间生成一次性启动票据的本地密钥 |
|
||||
| `require_manifest_signature` | 是否强制要求 Manifest 必须带签名 |
|
||||
| `verify_installed_on_start` | 主程序启动时是否按 Manifest 缓存校验已安装文件 |
|
||||
|
||||
`config/server_config.json` 会编译进客户端资源,作为 `api_base_url` 缺失时的兜底地址,当前测试服务器地址为:
|
||||
|
||||
`http://192.168.1.158:18000`
|
||||
|
||||
如果换服务器,可以改完该文件后重新编译客户端;正式客户包通常由服务端写入 `api_base_url`,不需要把 `server_config.json` 暴露给客户。
|
||||
|
||||
## 5. 编译和打包 SDK
|
||||
|
||||
客户端编译、带 Qt 和不带 Qt 两种 SDK 打包方式、输出目录、输出 ZIP 文件名,统一维护在 [../打包成SDK.md](../打包成SDK.md)。
|
||||
|
||||
本文件只说明 SDK 在客户软件里的接入位置和运行逻辑,不重复维护打包命令。
|
||||
|
||||
SDK 包不会包含最终 `app_config.json`、`server_config.json`、`server_config.qrc` 或 `manifest_public_key.pem`。这些最终配置在完整客户软件包或 Qt IFW 交付包上传到 SimCAE Hub 后由服务端生成。
|
||||
|
||||
## 6. 客户安装包配置
|
||||
|
||||
接入方应把以下文件放到客户软件目录的根目录或 `bin/` 目录:
|
||||
|
||||
1. `Launcher`
|
||||
2. `Updater`
|
||||
3. `Bootstrap`
|
||||
4. `MainApp` 或真实业务主程序
|
||||
5. `config/` 目录,可以先为空
|
||||
|
||||
### 6.1 标准目录结构和路径口径
|
||||
|
||||
更新系统不要求必须放在客户软件根目录。它可以放在 `SimCAE/` 根目录,也可以放在 `SimCAE/bin/` 目录。关键是让客户端配置里的 `install_root` 和服务端 Manifest 文件路径使用同一套口径。
|
||||
|
||||
先区分三个目录概念:
|
||||
|
||||
| 概念 | 说明 |
|
||||
| --- | --- |
|
||||
| 运行目录 | Launcher、Updater、Bootstrap 所在目录,由客户端自动识别 |
|
||||
| `install_root` | 相对运行目录解析的更新根目录,Updater 下载、校验、备份、回滚和 Bootstrap 替换文件都以它为范围 |
|
||||
| Manifest `files[].path` | 服务端生成的安装相对路径,客户端会把它拼到 `install_root` 下面 |
|
||||
|
||||
目录结构一:更新系统放在软件根目录。
|
||||
|
||||
```text
|
||||
SimCAE/
|
||||
Launcher.exe
|
||||
Updater.exe
|
||||
Bootstrap.exe
|
||||
MainApp.exe
|
||||
config/
|
||||
app_config.json
|
||||
App/
|
||||
...
|
||||
```
|
||||
|
||||
对应配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"install_root": ".",
|
||||
"main_executable": "MainApp.exe",
|
||||
"launcher_executable": "Launcher.exe",
|
||||
"updater_executable": "Updater.exe",
|
||||
"bootstrap_executable": "Bootstrap.exe"
|
||||
}
|
||||
```
|
||||
|
||||
目录结构二:更新系统和启动入口放在 `bin/`。
|
||||
|
||||
```text
|
||||
SimCAE/
|
||||
bin/
|
||||
Launcher.exe
|
||||
Updater.exe
|
||||
Bootstrap.exe
|
||||
MainApp.exe
|
||||
config/
|
||||
app_config.json
|
||||
App/
|
||||
...
|
||||
```
|
||||
|
||||
如果希望整个 `SimCAE/` 都属于更新范围,对应配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"install_root": "..",
|
||||
"main_executable": "MainApp.exe",
|
||||
"launcher_executable": "Launcher.exe",
|
||||
"updater_executable": "Updater.exe",
|
||||
"bootstrap_executable": "Bootstrap.exe"
|
||||
}
|
||||
```
|
||||
|
||||
这表示 Launcher 从 `bin/` 启动 `MainApp.exe`,Updater 和 Bootstrap 更新的是 `bin/` 的上一级,也就是整个 `SimCAE/`。
|
||||
|
||||
服务端发布包路径要和客户端 `install_root` 对应:
|
||||
|
||||
| 客户端配置 | 服务端 Manifest 路径口径 |
|
||||
| --- | --- |
|
||||
| 更新系统在根目录,`install_root` 是 `.` | `MainApp.exe`、`App/xxx.dll` 相对 `SimCAE/` |
|
||||
| 更新系统在 `bin/`,`install_root` 是 `..` | `bin/MainApp.exe`、`App/xxx.dll` 相对 `SimCAE/` |
|
||||
|
||||
当前管理后台“发布包”上传接口会使用上传文件名作为 `artifactName`,后端按安全文件名校验。当前稳定支持的是发布一个完整安装包或压缩包文件,或者把文件放在 `install_root` 根层级;还不是“自动解析压缩包并生成 App/bin 多文件 Manifest”的完整安装器。后续如果要让在线 Updater 直接把多个文件铺到 `App/`、`bin/` 等子目录,需要在现有发布包页面和 Go 后端上继续增强安全相对路径或 Manifest 文件清单生成能力。
|
||||
|
||||
如果后续要支持“更新系统在 `bin/`,但只校验和更新 `App/`”这类更窄的安装根目录,需要在管理后台和 Go 后端增加对应配置项,让服务端生成 `../App` 这类定制 `install_root`。当前服务端自动生成配置时只使用标准的 `.` 或 `..`。
|
||||
|
||||
如果开启 `verify_installed_on_start`,客户端会扫描 `install_root` 下的 EXE 和 DLL。整包 Manifest 中 `required=true` 的核心文件必须存在且 SHA-256 匹配;`required=false` 的可选组件文件可以由 MaintenanceTool 管理,缺失时不会阻止启动。可选组件建议放在独立目录中,例如 `plugins/dap/`,不要和核心程序 DLL 混放。
|
||||
|
||||
上传完整客户软件 ZIP 时,服务端会根据发布包记录自动写入这些关键值:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_code": "stage2-dap",
|
||||
"channel": "stable",
|
||||
"current_version": "1.1.0",
|
||||
"api_base_url": "http://192.168.1.158:18000",
|
||||
"client_token": "<由服务器 .env 配置>",
|
||||
"install_root": ". 或 ..",
|
||||
"platform": "windows",
|
||||
"arch": "x86_64",
|
||||
"abi": "msvc"
|
||||
}
|
||||
```
|
||||
|
||||
`install_root` 由服务端根据 Launcher 所在位置自动判断:更新系统在软件根目录时写 `.`,在 `bin/` 目录时写 `..`。
|
||||
|
||||
## 7. 运行数据位置
|
||||
|
||||
Windows 运行数据目录:
|
||||
|
||||
`%LOCALAPPDATA%\SimCAE\HubUpdateClient\installations\<安装目录SHA256>\`
|
||||
|
||||
Linux 运行数据目录:
|
||||
|
||||
`$XDG_DATA_HOME/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
|
||||
|
||||
未设置 `XDG_DATA_HOME` 时通常是:
|
||||
|
||||
`~/.local/share/SimCAE/HubUpdateClient/installations/<安装目录SHA256>/`
|
||||
|
||||
Manifest 缓存保存在运行数据目录下的 `update/manifest_cache`。
|
||||
|
||||
## 8. 常见问题
|
||||
|
||||
1. 客户门户登录失败:检查客户门户账号是否已激活、客户是否生效、密码是否正确。
|
||||
2. 检查更新没有结果:检查后台发布是否已发布、发布包是否可用、产品编码、渠道和平台参数是否一致。
|
||||
3. 下载 401:检查发布包中的 `config/app_config.json` 是否由服务端生成,`client_token` 是否和服务器 `.env` 中的 `SIMCAE_CLIENT_TOKEN` 一致。
|
||||
4. Manifest 校验失败:检查服务端 Manifest 是否被篡改、发布包 SHA-256 是否和实际文件一致。
|
||||
5. 强制签名失败:确认 `manifest_public_key.pem` 与服务端私钥匹配;如果服务端暂未启用签名,测试环境可先把 `require_manifest_signature` 设为 `false`。
|
||||
6. 启动主程序失败:检查 `main_executable` 和 `install_root` 是否指向真实文件。
|
||||
@@ -1,75 +0,0 @@
|
||||
# SimCAE Hub 客户端编译环境和第三方依赖说明
|
||||
|
||||
本文说明 `update-client` 的 Qt/C++ 客户端编译环境。客户端保留 Launcher / Updater / Bootstrap 机制,依赖 Qt、CMake 和 OpenSSL。
|
||||
|
||||
## 1. 通用要求
|
||||
|
||||
| 依赖 | 要求 |
|
||||
| --- | --- |
|
||||
| CMake | 建议 3.20 或更高版本 |
|
||||
| C++ | C++17 |
|
||||
| Qt | Qt 5,至少需要 Core、Network、Gui、Widgets |
|
||||
| OpenSSL | 用于 Manifest RSA-SHA256 验签 |
|
||||
| 编译器 | Windows 推荐 Visual Studio 2022 x64,Linux 推荐 gcc/g++ |
|
||||
|
||||
项目已提供 CMake Preset:
|
||||
|
||||
| Preset | 平台 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `x64-debug` | Windows | Debug 编译 |
|
||||
| `x64-release` | Windows | Release 编译 |
|
||||
| `linux-x64-debug` | Linux | Debug 编译 |
|
||||
| `linux-x64-release` | Linux | Release 编译 |
|
||||
|
||||
## 2. Windows 环境
|
||||
|
||||
建议安装:
|
||||
|
||||
1. Visual Studio 2022,勾选 Desktop development with C++。
|
||||
2. Qt 5 x64,版本可以与当前团队环境保持一致。
|
||||
3. CMake。
|
||||
4. OpenSSL x64。
|
||||
|
||||
如果 Qt 没有加入环境变量,可以在编译前指定 `CMAKE_PREFIX_PATH` 或 `Qt5_DIR`。示例:
|
||||
|
||||
```powershell
|
||||
$env:CMAKE_PREFIX_PATH = "C:\Qt\5.15.2\msvc2019_64"
|
||||
```
|
||||
|
||||
如果 Qt 安装在别的位置,只改这一行。
|
||||
|
||||
OpenSSL 可以放在 `update-client/thirdparty/OpenSSL-Win64`,也可以在配置时通过 `SIMCAE_OPENSSL_ROOT` 指向自定义目录。
|
||||
|
||||
## 3. Linux 环境
|
||||
|
||||
Ubuntu 示例:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential cmake qtbase5-dev qttools5-dev qttools5-dev-tools libssl-dev
|
||||
```
|
||||
|
||||
Linux 下通常直接使用系统 OpenSSL;如需指定自定义 OpenSSL,可用 CMake 变量配置。
|
||||
|
||||
## 4. 编译输出
|
||||
|
||||
Windows Release 可执行文件输出目录:
|
||||
|
||||
`update-client/out/bin/Release`
|
||||
|
||||
Windows CMake 构建目录:
|
||||
|
||||
`update-client/out/build/x64-release`
|
||||
|
||||
Linux Release 可执行文件输出目录以当前 CMake Preset 和构建脚本为准,SDK 打包时通过 `--source-dir` 指定。
|
||||
|
||||
实际打包 SDK 前,应确认 Release 输出目录中至少包含:
|
||||
|
||||
1. `Launcher`
|
||||
2. `Updater`
|
||||
3. `Bootstrap`
|
||||
4. 可选的示例 `MainApp`
|
||||
|
||||
最终客户软件包里的 `config/app_config.json` 和 `config/manifest_public_key.pem` 由服务端在发布包上传时生成;`server_config.json` 会编译进 EXE 作为兜底地址,不需要进入 SDK 包。
|
||||
|
||||
SDK 打包见 `../打包成SDK.md`,客户端运行链路和接入限制见 `01-客户端接入打包部署指南.md`。
|
||||
+31
-38
@@ -1,42 +1,35 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
project(LauncherDemo LANGUAGES C CXX)
|
||||
# C++标准
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
# Qt 自动处理ui/moc/rcc
|
||||
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
|
||||
# 所有源码文件
|
||||
set(SRC_LIST
|
||||
set(_launcher_sources
|
||||
main.cpp
|
||||
UpdateLogic.h
|
||||
UpdateLogic.cpp
|
||||
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||
)
|
||||
# 生成可执行程序
|
||||
add_executable(Launcher ${SRC_LIST})
|
||||
UpdateLogic.h
|
||||
UpdateLogic.cpp
|
||||
)
|
||||
|
||||
if(WIN32)
|
||||
set(_launcher_manifest "${CMAKE_CURRENT_SOURCE_DIR}/launcher.manifest")
|
||||
if(NOT EXISTS "${_launcher_manifest}")
|
||||
message(FATAL_ERROR "Launcher UAC manifest is missing: ${_launcher_manifest}")
|
||||
endif()
|
||||
list(APPEND _launcher_sources "${_launcher_manifest}")
|
||||
endif()
|
||||
|
||||
add_executable(Launcher ${_launcher_sources})
|
||||
target_compile_features(Launcher PRIVATE cxx_std_17)
|
||||
target_link_libraries(Launcher
|
||||
PRIVATE
|
||||
Common
|
||||
UpdateClientUpdaterLogic
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
update_client_link_embedded_resources(Launcher)
|
||||
update_client_deploy_openssl_runtime(Launcher)
|
||||
update_client_enable_qt_deployment(Launcher)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Launcher PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
|
||||
# 关键:添加OpenSSL头文件目录
|
||||
target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
|
||||
|
||||
# 链接Common,自动带上OpenSSL库
|
||||
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
|
||||
# 强制VS打开CMake文件夹时默认选中该可执行目标
|
||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET Launcher POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Launcher>
|
||||
COMMENT "自动部署Qt依赖dll"
|
||||
)
|
||||
if(MSVC)
|
||||
target_link_options(Launcher PRIVATE /MANIFESTUAC:NO)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
+78
-248
@@ -1,287 +1,117 @@
|
||||
#include "UpdateLogic.h"
|
||||
|
||||
#include "ConfigHelper.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QSaveFile>
|
||||
#include <QUrl>
|
||||
#include <QUrlQuery>
|
||||
|
||||
namespace {
|
||||
|
||||
QString trimBaseUrl(QString value)
|
||||
{
|
||||
value = value.trimmed();
|
||||
while (value.endsWith(QLatin1Char('/')))
|
||||
value.chop(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
|
||||
{
|
||||
const QString trimmed = value.trimmed();
|
||||
if (!trimmed.isEmpty())
|
||||
query.addQueryItem(key, trimmed);
|
||||
}
|
||||
|
||||
QString serverMessage(const QJsonObject& response)
|
||||
{
|
||||
const QString msg = response.value(QStringLiteral("msg")).toString();
|
||||
if (!msg.isEmpty())
|
||||
return msg;
|
||||
|
||||
const QJsonValue detail = response.value(QStringLiteral("detail"));
|
||||
if (detail.isObject()) {
|
||||
const QJsonObject object = detail.toObject();
|
||||
const QString detailMsg = object.value(QStringLiteral("msg")).toString();
|
||||
if (!detailMsg.isEmpty())
|
||||
return detailMsg;
|
||||
const QString error = object.value(QStringLiteral("error")).toString();
|
||||
if (!error.isEmpty())
|
||||
return error;
|
||||
}
|
||||
return detail.toString();
|
||||
}
|
||||
|
||||
QJsonObject responseDataObject(const QJsonObject& response)
|
||||
{
|
||||
return response.value(QStringLiteral("data")).isObject()
|
||||
? response.value(QStringLiteral("data")).toObject()
|
||||
: response;
|
||||
}
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#include <QApplication>
|
||||
#include "PolicyHelper.h"
|
||||
#include "LocalStateHelper.h"
|
||||
|
||||
UpdateLogic::UpdateLogic(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
ConfigHelper& cfg = ConfigHelper::instance();
|
||||
m_serverAddr = trimBaseUrl(cfg.getValue("Server", "api_base_url"));
|
||||
m_appId = cfg.getValue("App", "product_code").trimmed();
|
||||
if (m_appId.isEmpty())
|
||||
m_appId = cfg.getValue("App", "app_id").trimmed();
|
||||
m_curVer = cfg.getValue("App", "current_version").trimmed();
|
||||
m_channel = cfg.getValue("App", "channel").trimmed();
|
||||
if (m_channel.isEmpty())
|
||||
m_channel = QStringLiteral("stable");
|
||||
m_serverAddr = cfg.getValue("Server", "api_base_url");
|
||||
m_appId = cfg.getValue("App", "app_id");
|
||||
m_curVer = cfg.getValue("App", "current_version");
|
||||
m_channel = cfg.getValue("App", "channel");
|
||||
|
||||
// Debug print config
|
||||
qDebug() << "Read server addr:" << m_serverAddr;
|
||||
qDebug() << "Read product code:" << m_appId;
|
||||
qDebug() << "Read app id:" << m_appId;
|
||||
}
|
||||
|
||||
void UpdateLogic::checkUpdate()
|
||||
{
|
||||
m_error.clear();
|
||||
m_needUpdate = false;
|
||||
m_networkOk = false;
|
||||
m_lastStatusCode = 0;
|
||||
m_latestVer.clear();
|
||||
m_checkResp = QJsonObject();
|
||||
|
||||
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty())
|
||||
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty())
|
||||
{
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"Update configuration is incomplete. Server, product_code and current_version are required.");
|
||||
qDebug() << "Config incomplete, abort update check:" << m_error;
|
||||
return;
|
||||
}
|
||||
if (configValue(QStringLiteral("client_token")).isEmpty())
|
||||
{
|
||||
m_lastStatusCode = 401;
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"Update configuration is incomplete. client_token is required.");
|
||||
m_checkResp.insert(QStringLiteral("msg"), m_error);
|
||||
qDebug() << "Client token missing, abort update check:" << m_error;
|
||||
qDebug() << "Config incomplete, abort update check";
|
||||
m_needUpdate = false;
|
||||
return;
|
||||
}
|
||||
QString url = m_serverAddr + "/api/v1/update/check";
|
||||
QJsonObject body;
|
||||
body["app_id"] = m_appId;
|
||||
body["current_version"] = m_curVer;
|
||||
body["channel"] = m_channel;
|
||||
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
|
||||
body["client_protocol"] = qMax(3, configuredProtocol);
|
||||
|
||||
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/authorized-check"));
|
||||
QUrlQuery query;
|
||||
addQueryValue(query, QStringLiteral("productCode"), m_appId);
|
||||
addQueryValue(query, QStringLiteral("currentVersion"), m_curVer);
|
||||
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
|
||||
addQueryValue(query, QStringLiteral("channel"), m_channel);
|
||||
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
|
||||
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
|
||||
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
|
||||
url.setQuery(query);
|
||||
|
||||
m_http.getRequest(url.toString(QUrl::FullyEncoded),
|
||||
[this](int code, const QJsonObject& resp)
|
||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "Check update HTTP code:" << code;
|
||||
m_lastStatusCode = code;
|
||||
m_checkResp = resp;
|
||||
m_networkOk = (code == 200);
|
||||
if (code != 200)
|
||||
if (code == 200)
|
||||
{
|
||||
const QString appDir = QApplication::applicationDirPath();
|
||||
PolicyHelper onlinePolicy(appDir);
|
||||
LocalStateHelper state(appDir);
|
||||
const bool stateLoaded = state.loadState();
|
||||
const bool policyValid = onlinePolicy.loadPolicyObject(
|
||||
resp.value("policy").toObject(), resp.value("policy_text").toString());
|
||||
if (!policyValid || !stateLoaded
|
||||
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|
||||
|| !onlinePolicy.savePolicy())
|
||||
{
|
||||
qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
|
||||
m_networkOk = false;
|
||||
m_needUpdate = false;
|
||||
return;
|
||||
}
|
||||
state.updateOnlineVerified(onlinePolicy.policySeq());
|
||||
if (!state.saveState())
|
||||
{
|
||||
qDebug() << "Cannot persist online policy state";
|
||||
m_networkOk = false;
|
||||
m_needUpdate = false;
|
||||
return;
|
||||
}
|
||||
m_needUpdate = resp["need_update"].toBool();
|
||||
m_latestVer = resp["latest_version"].toString();
|
||||
qDebug() << "Need update:" << m_needUpdate;
|
||||
qDebug() << "Latest version:" << m_latestVer;
|
||||
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
|
||||
}
|
||||
else
|
||||
{
|
||||
m_needUpdate = false;
|
||||
m_error = serverMessage(resp);
|
||||
qDebug() << "Check update api failed:" << m_error;
|
||||
return;
|
||||
qDebug() << "Check update api failed";
|
||||
}
|
||||
|
||||
const QJsonArray releases = resp.value(QStringLiteral("data")).toArray();
|
||||
QJsonObject selected;
|
||||
for (const QJsonValue& value : releases) {
|
||||
const QJsonObject release = value.toObject();
|
||||
const bool hasPackages = !release.value(QStringLiteral("packages")).toArray().isEmpty();
|
||||
const bool hasManifest = release.value(QStringLiteral("manifest")).isObject()
|
||||
|| !release.value(QStringLiteral("manifestUri")).toString().isEmpty();
|
||||
if (hasPackages && hasManifest) {
|
||||
selected = release;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty()) {
|
||||
m_needUpdate = false;
|
||||
if (!releases.isEmpty())
|
||||
m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString();
|
||||
qDebug() << "No entitled downloadable update for current client.";
|
||||
return;
|
||||
}
|
||||
|
||||
m_checkResp = selected;
|
||||
m_checkResp.insert(QStringLiteral("need_update"), true);
|
||||
m_checkResp.insert(QStringLiteral("latest_version"), selected.value(QStringLiteral("version")).toString());
|
||||
m_checkResp.insert(QStringLiteral("release_id"), selected.value(QStringLiteral("id")).toString());
|
||||
m_needUpdate = true;
|
||||
m_latestVer = selected.value(QStringLiteral("version")).toString();
|
||||
qDebug() << "Need update:" << m_needUpdate;
|
||||
qDebug() << "Latest version:" << m_latestVer;
|
||||
qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString();
|
||||
});
|
||||
}
|
||||
|
||||
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
|
||||
{
|
||||
Q_UNUSED(appId);
|
||||
Q_UNUSED(channel);
|
||||
Q_UNUSED(ver);
|
||||
Q_UNUSED(verId);
|
||||
qDebug() << "SimCAE Hub manifest already contains authorized package download URLs.";
|
||||
}
|
||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
||||
QJsonObject body;
|
||||
body["app_id"] = appId;
|
||||
body["channel"] = channel;
|
||||
body["version"] = ver;
|
||||
body["version_id"] = verId;
|
||||
QJsonArray files;
|
||||
body["files"] = files;
|
||||
|
||||
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
|
||||
int versionId, const QString& cacheDir)
|
||||
{
|
||||
Q_UNUSED(versionId);
|
||||
m_error.clear();
|
||||
if (appId.isEmpty() || channel.isEmpty() || version.isEmpty()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"Current version manifest identity is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
|
||||
.arg(appId, channel, version);
|
||||
return false;
|
||||
}
|
||||
|
||||
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
|
||||
QUrlQuery query;
|
||||
addQueryValue(query, QStringLiteral("productCode"), appId);
|
||||
addQueryValue(query, QStringLiteral("version"), version);
|
||||
addQueryValue(query, QStringLiteral("clientVersion"), configValue(QStringLiteral("client_protocol"), QStringLiteral("3")));
|
||||
addQueryValue(query, QStringLiteral("channel"), channel);
|
||||
addQueryValue(query, QStringLiteral("os"), configValue(QStringLiteral("platform")));
|
||||
addQueryValue(query, QStringLiteral("architecture"), configValue(QStringLiteral("arch")));
|
||||
addQueryValue(query, QStringLiteral("abi"), configValue(QStringLiteral("abi")));
|
||||
url.setQuery(query);
|
||||
|
||||
QJsonObject response;
|
||||
int statusCode = 0;
|
||||
m_http.getRequest(url.toString(QUrl::FullyEncoded),
|
||||
[&](int code, const QJsonObject& resp) {
|
||||
statusCode = code;
|
||||
response = resp;
|
||||
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "\n========== File Download Url ==========";
|
||||
qDebug() << resp["files"].toArray();
|
||||
});
|
||||
|
||||
if (statusCode != 200) {
|
||||
const QString message = serverMessage(response);
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"Cannot download manifest for the current local version. Stage: cache current version manifest. HTTP status: %1. Product: %2, channel: %3, version: %4.%5")
|
||||
.arg(QString::number(statusCode), appId, channel, version,
|
||||
message.isEmpty() ? QString() : QCoreApplication::translate("UpdateLogic", "\nServer message: %1").arg(message));
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject wrapper = responseDataObject(response);
|
||||
const QJsonObject manifest = wrapper.value(QStringLiteral("manifest")).toObject();
|
||||
QString manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
|
||||
if (manifestText.isEmpty())
|
||||
manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
|
||||
if (manifest.isEmpty() || manifestText.isEmpty()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"The manifest response for the current local version is incomplete. Stage: cache current version manifest. Product: %1, channel: %2, version: %3.")
|
||||
.arg(appId, channel, version);
|
||||
return false;
|
||||
}
|
||||
const QString manifestProduct = manifest.value(QStringLiteral("productCode")).toString(
|
||||
manifest.value(QStringLiteral("app_id")).toString());
|
||||
if (manifestProduct != appId
|
||||
|| manifest.value(QStringLiteral("channel")).toString() != channel
|
||||
|| manifest.value(QStringLiteral("version")).toString() != version) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"The manifest identity does not match the current local version. Stage: cache current version manifest. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
|
||||
.arg(appId, channel, version,
|
||||
manifestProduct,
|
||||
manifest.value(QStringLiteral("channel")).toString(),
|
||||
manifest.value(QStringLiteral("version")).toString());
|
||||
return false;
|
||||
}
|
||||
|
||||
QDir dir(cacheDir);
|
||||
if (!dir.exists() && !dir.mkpath(".")) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"Cannot create manifest cache directory. Stage: cache current version manifest. Directory: %1.")
|
||||
.arg(cacheDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
wrapper.insert(QStringLiteral("manifest"), manifest);
|
||||
wrapper.insert(QStringLiteral("manifestText"), manifestText);
|
||||
wrapper.insert(QStringLiteral("manifest_text"), manifestText);
|
||||
QSaveFile file(dir.filePath(QStringLiteral("manifest_") + version + QStringLiteral(".json")));
|
||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Indented);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||
m_error = QCoreApplication::translate(
|
||||
"UpdateLogic",
|
||||
"Cannot save manifest cache. Stage: cache current version manifest. File: %1. Error: %2.")
|
||||
.arg(file.fileName(), file.errorString());
|
||||
return false;
|
||||
}
|
||||
qDebug() << "Current version manifest cached to" << file.fileName();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool UpdateLogic::refreshGitTagsFile(const QString& outputPath)
|
||||
{
|
||||
Q_UNUSED(outputPath);
|
||||
m_error.clear();
|
||||
qDebug() << "Git tag export is not part of the current SimCAE Hub admin pages; skipped.";
|
||||
return true;
|
||||
}
|
||||
|
||||
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
|
||||
{
|
||||
Q_UNUSED(deviceId);
|
||||
Q_UNUSED(fromVer);
|
||||
Q_UNUSED(toVer);
|
||||
Q_UNUSED(success);
|
||||
qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
|
||||
QString url = m_serverAddr + "/api/v1/update/report";
|
||||
QJsonObject body;
|
||||
body["app_id"] = m_appId;
|
||||
body["device_id"] = deviceId;
|
||||
body["from_version"] = fromVer;
|
||||
body["to_version"] = toVer;
|
||||
body["result"] = success ? "success" : "fail";
|
||||
|
||||
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
|
||||
{
|
||||
qDebug() << "\n========== Update Report Result ==========";
|
||||
qDebug() << resp;
|
||||
});
|
||||
}
|
||||
@@ -13,19 +13,14 @@ public:
|
||||
void checkUpdate();
|
||||
void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId);
|
||||
void reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
|
||||
bool cacheManifest(const QString& appId, const QString& channel, const QString& version,
|
||||
int versionId, const QString& cacheDir);
|
||||
bool refreshGitTagsFile(const QString& outputPath);
|
||||
|
||||
bool getNeedUpdate() const { return m_needUpdate; }
|
||||
QString getLatestVersion() const { return m_latestVer; }
|
||||
QJsonObject getCheckResult() const { return m_checkResp; }
|
||||
bool isNetworkOk() const { return m_networkOk; }
|
||||
int lastStatusCode() const { return m_lastStatusCode; }
|
||||
QString errorString() const { return m_error; }
|
||||
|
||||
QString getAppId() const { return m_appId; }
|
||||
QString getProductCode() const { return m_appId; }
|
||||
QString getChannel() const { return m_channel; }
|
||||
|
||||
private:
|
||||
@@ -40,5 +35,4 @@ private:
|
||||
int m_lastStatusCode = 0;
|
||||
QString m_latestVer;
|
||||
QJsonObject m_checkResp;
|
||||
QString m_error;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
+382
-158
@@ -1,83 +1,34 @@
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QProgressDialog>
|
||||
#include <QTranslator>
|
||||
#include <QUuid>
|
||||
|
||||
#include <QInputDialog>
|
||||
#include <QLineEdit>
|
||||
#include "UpdateLogic.h"
|
||||
#include "../Common/ConfigHelper.h"
|
||||
#include "../Common/PolicyHelper.h"
|
||||
#include "../Common/LocalStateHelper.h"
|
||||
#include "../Common/TicketHelper.h"
|
||||
#include "../Common/DeviceIdentityHelper.h"
|
||||
#include "../Common/IntegrityHelper.h"
|
||||
#include "../Common/ManifestBootstrapPolicy.h"
|
||||
#include "../Common/TranslationHelper.h"
|
||||
#include "../Updater/UpdaterLogic.h"
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QDir>
|
||||
|
||||
namespace {
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
QString productCode()
|
||||
{
|
||||
return configValue(QStringLiteral("product_code"),
|
||||
configValue(QStringLiteral("app_id")));
|
||||
}
|
||||
|
||||
QString ensureDeviceId()
|
||||
{
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
QString deviceId = config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")).trimmed();
|
||||
if (!deviceId.isEmpty())
|
||||
return deviceId;
|
||||
|
||||
deviceId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id")).trimmed();
|
||||
if (deviceId.isEmpty())
|
||||
deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
|
||||
config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), deviceId);
|
||||
config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), deviceId);
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
QString authFailureMessage(const QJsonObject& response, const QString& fallback)
|
||||
{
|
||||
const QString msg = response.value(QStringLiteral("msg")).toString();
|
||||
if (!msg.isEmpty())
|
||||
return msg;
|
||||
const QJsonValue detail = response.value(QStringLiteral("detail"));
|
||||
if (detail.isObject()) {
|
||||
const QJsonObject object = detail.toObject();
|
||||
const QString detailMsg = object.value(QStringLiteral("msg")).toString();
|
||||
if (!detailMsg.isEmpty())
|
||||
return detailMsg;
|
||||
const QString error = object.value(QStringLiteral("error")).toString();
|
||||
if (!error.isEmpty())
|
||||
return error;
|
||||
}
|
||||
const QString detailText = detail.toString();
|
||||
return detailText.isEmpty() ? fallback : detailText;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("SimCAE Launcher");
|
||||
QTranslator translator;
|
||||
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
|
||||
app.installTranslator(&translator);
|
||||
QApplication::setApplicationName("Marsco Launcher");
|
||||
|
||||
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
|
||||
if (elevatedWriteExitCode >= 0)
|
||||
return elevatedWriteExitCode;
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
|
||||
progress.setWindowTitle(QCoreApplication::translate("Launcher", "SimCAE Launcher"));
|
||||
progress.setWindowTitle(QCoreApplication::translate("Launcher", "Marsco Launcher"));
|
||||
progress.setCancelButton(nullptr);
|
||||
progress.setWindowModality(Qt::ApplicationModal);
|
||||
progress.setMinimumDuration(0);
|
||||
@@ -85,11 +36,120 @@ int main(int argc, char* argv[])
|
||||
progress.show();
|
||||
QApplication::processEvents();
|
||||
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
const QString appDir = QApplication::applicationDirPath();
|
||||
ConfigHelper &config = ConfigHelper::instance();
|
||||
const auto isLicenseError = [&translation](const QString &errorText)
|
||||
{
|
||||
const QString text = errorText.toLower();
|
||||
QStringList markers{QStringLiteral("license")};
|
||||
const char *const markerSources[]{
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "authorization"),
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "expired"),
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "device limit reached"),
|
||||
QT_TRANSLATE_NOOP("LauncherErrorMarker", "bound to another license")};
|
||||
for (const char *source : markerSources)
|
||||
{
|
||||
markers.append(QCoreApplication::translate("LauncherErrorMarker", source));
|
||||
markers.append(translation.translate("LauncherErrorMarker", source));
|
||||
}
|
||||
for (const QString &marker : markers)
|
||||
{
|
||||
if (!marker.isEmpty() && text.contains(marker.toLower()))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const auto clearDeviceCredential = [&]()
|
||||
{
|
||||
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
|
||||
config.setValue("Update", "device_id", QString());
|
||||
};
|
||||
QString licenseKey = config.getValue("License", "license_key").trimmed();
|
||||
auto promptAndSaveLicense = [&](const QString &reason) -> QString
|
||||
{
|
||||
QString promptReason = reason;
|
||||
while (true)
|
||||
{
|
||||
progress.close();
|
||||
bool accepted = false;
|
||||
const QString appName = config.getValue("App", "app_name").trimmed();
|
||||
const QString displayName = appName.isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "application")
|
||||
: appName;
|
||||
const QString prompt = promptReason.trimmed().isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "Enter the license for %1:").arg(displayName)
|
||||
: QCoreApplication::translate("Launcher", "%1\n\nPlease re-enter the license for %2:").arg(promptReason, displayName);
|
||||
licenseKey = QInputDialog::getText(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Enter License"),
|
||||
prompt,
|
||||
QLineEdit::Normal,
|
||||
QString(),
|
||||
&accepted)
|
||||
.trimmed();
|
||||
if (!accepted)
|
||||
{
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "License Required"),
|
||||
QCoreApplication::translate("Launcher", "A license provided by an administrator is required for the first launch."));
|
||||
return QString();
|
||||
}
|
||||
if (licenseKey.isEmpty())
|
||||
{
|
||||
QMessageBox::warning(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "License Cannot Be Empty"),
|
||||
QCoreApplication::translate("Launcher", "Paste the license created by an administrator."));
|
||||
promptReason.clear();
|
||||
continue;
|
||||
}
|
||||
if (!config.setValue("License", "license_key", licenseKey))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Failed to Save License"),
|
||||
QCoreApplication::translate("Launcher", "Cannot save system state at %1:\n%2")
|
||||
.arg(config.configPath(), config.lastError()));
|
||||
return QString();
|
||||
}
|
||||
progress.show();
|
||||
progress.setLabelText(QCoreApplication::translate("Launcher", "Verifying license..."));
|
||||
QApplication::processEvents();
|
||||
return licenseKey;
|
||||
}
|
||||
};
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (licenseKey.isEmpty())
|
||||
{
|
||||
licenseKey = promptAndSaveLicense(QString());
|
||||
if (licenseKey.isEmpty())
|
||||
return 0;
|
||||
}
|
||||
DeviceIdentityHelper identity(appDir);
|
||||
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
|
||||
config.getValue("Server", "client_token"),
|
||||
config.getValue("App", "app_id"),
|
||||
config.getValue("App", "channel"),
|
||||
licenseKey))
|
||||
{
|
||||
break;
|
||||
}
|
||||
const QString error = identity.errorString();
|
||||
if (!isLicenseError(error))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Device Authentication Failed"), error);
|
||||
return -1;
|
||||
}
|
||||
clearDeviceCredential();
|
||||
licenseKey = promptAndSaveLicense(QCoreApplication::translate("Launcher", "The current license cannot be used: %1").arg(error));
|
||||
if (licenseKey.isEmpty())
|
||||
return 0;
|
||||
}
|
||||
|
||||
progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates..."));
|
||||
QApplication::processEvents();
|
||||
UpdateLogic logic;
|
||||
logic.checkUpdate();
|
||||
|
||||
@@ -97,138 +157,302 @@ int main(int argc, char* argv[])
|
||||
const bool networkOk = logic.isNetworkOk();
|
||||
const QString latestVer = logic.getLatestVersion();
|
||||
const QJsonObject response = logic.getCheckResult();
|
||||
const QString releaseId = response.value(QStringLiteral("release_id")).toString(
|
||||
response.value(QStringLiteral("id")).toString());
|
||||
const QString appId = logic.getProductCode();
|
||||
const int targetVersionId = response.value("version_id").toInt();
|
||||
const QString appId = logic.getAppId();
|
||||
const QString channel = logic.getChannel();
|
||||
const QString launchToken = config.getValue(QStringLiteral("App"), QStringLiteral("launch_token"));
|
||||
const QString currentVersion = config.getValue(QStringLiteral("App"), QStringLiteral("current_version"));
|
||||
const QString deviceId = ensureDeviceId();
|
||||
|
||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||
return ConfigHelper::executableNameForCurrentPlatform(
|
||||
config.getValue(QStringLiteral("Runtime"), key), fallback);
|
||||
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403)
|
||||
{
|
||||
progress.close();
|
||||
const QJsonValue detail = response.value("detail");
|
||||
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
|
||||
const QString displayMessage = message.isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "The device or license authorization is invalid.")
|
||||
: message;
|
||||
if (isLicenseError(displayMessage))
|
||||
{
|
||||
clearDeviceCredential();
|
||||
const QString newLicense = promptAndSaveLicense(
|
||||
QCoreApplication::translate("Launcher", "The server rejected the current license: %1").arg(displayMessage));
|
||||
if (!newLicense.isEmpty())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "License Saved"),
|
||||
QCoreApplication::translate("Launcher", "Restart Launcher to complete device authorization and check for updates."));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Launcher", "Authorization Denied"), displayMessage);
|
||||
return -1;
|
||||
}
|
||||
|
||||
const QString launchToken = config.getValue("App", "launch_token");
|
||||
const QString currentVersion = config.getValue("App", "current_version");
|
||||
const auto configuredName = [&](const QString &key, const QString &fallback)
|
||||
{
|
||||
const QString value = config.getValue("Runtime", key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
};
|
||||
const QString mainExecutable = configuredName(QStringLiteral("main_executable"), QStringLiteral("MainApp"));
|
||||
const QString updaterExecutable = configuredName(QStringLiteral("updater_executable"), QStringLiteral("Updater"));
|
||||
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
|
||||
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
|
||||
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
|
||||
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
|
||||
|
||||
QString mainStartupError;
|
||||
const auto startMainApp = [&]() {
|
||||
mainStartupError.clear();
|
||||
if (!QFileInfo::exists(mainAppPath)) {
|
||||
mainStartupError = QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
|
||||
.arg(mainAppPath);
|
||||
return false;
|
||||
IntegrityHelper integrity(config.installRoot());
|
||||
bool integrityVerified = integrity.verifyInstalledVersion(
|
||||
appId, channel, currentVersion);
|
||||
QString integrityError = integrity.errorString();
|
||||
const UpdateClient::ManifestBootstrapAction bootstrapAction =
|
||||
UpdateClient::manifestBootstrapAction(
|
||||
integrity.isManifestCacheMissing(), networkOk, needUpdate,
|
||||
targetVersionId);
|
||||
if (!integrityVerified
|
||||
&& bootstrapAction
|
||||
== UpdateClient::ManifestBootstrapAction::FetchCurrentManifest)
|
||||
{
|
||||
UpdaterLogic manifestLogic;
|
||||
manifestLogic.getManifest(
|
||||
appId, channel, currentVersion, targetVersionId);
|
||||
const QString cacheDirectory = QDir(config.updateRoot()).filePath(
|
||||
QStringLiteral("manifest_cache"));
|
||||
if (manifestLogic.verifyManifestSignature()
|
||||
&& manifestLogic.saveManifestCache(cacheDirectory))
|
||||
{
|
||||
integrityVerified = integrity.verifyInstalledVersion(
|
||||
appId, channel, currentVersion);
|
||||
integrityError = integrity.errorString();
|
||||
}
|
||||
else
|
||||
{
|
||||
integrityError = QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"The signed manifest for version %1 could not be fetched or cached.")
|
||||
.arg(currentVersion);
|
||||
}
|
||||
}
|
||||
else if (!integrityVerified
|
||||
&& bootstrapAction
|
||||
== UpdateClient::ManifestBootstrapAction::CurrentVersionNotPublished)
|
||||
{
|
||||
integrityError = QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Version %1 is not published on the update server. Publish this exact installed build before distribution so its signed manifest can be verified.")
|
||||
.arg(currentVersion);
|
||||
}
|
||||
if (!integrityVerified)
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Integrity Check Failed"),
|
||||
QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Application files failed signed manifest verification:\n%1")
|
||||
.arg(integrityError));
|
||||
return -1;
|
||||
}
|
||||
const auto importOfflinePackage = [&]()
|
||||
{
|
||||
const QString package = QFileDialog::getOpenFileName(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Select Offline Update Package"),
|
||||
QString(),
|
||||
QCoreApplication::translate("Launcher", "Marsco Offline Update Package (*.upd)"));
|
||||
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
|
||||
};
|
||||
const QString deviceId = config.getValue("Update", "device_id");
|
||||
if (QCoreApplication::arguments().contains("--import-offline"))
|
||||
{
|
||||
progress.close();
|
||||
if (!importOfflinePackage())
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Offline Update"),
|
||||
QCoreApplication::translate("Launcher", "No offline update package was selected."));
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto startMainApp = [&]()
|
||||
{
|
||||
QString ticketPath;
|
||||
QString ticketError;
|
||||
if (!TicketHelper::createTicket(appId, deviceId, currentVersion,
|
||||
launchToken, &ticketPath, &ticketError)) {
|
||||
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
|
||||
launchToken, &ticketPath, &ticketError))
|
||||
{
|
||||
qDebug() << "Cannot create launch ticket:" << ticketError;
|
||||
mainStartupError = QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
|
||||
.arg(mainAppPath, ticketError);
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool started = QProcess::startDetached(mainAppPath,
|
||||
QStringList{QStringLiteral("--ticket-file=%1").arg(ticketPath)});
|
||||
if (!started) {
|
||||
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
|
||||
if (!started)
|
||||
QFile::remove(ticketPath);
|
||||
mainStartupError = QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
|
||||
.arg(mainAppPath, ticketPath);
|
||||
}
|
||||
return started;
|
||||
};
|
||||
|
||||
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
|
||||
progress.setLabelText(QCoreApplication::translate("Launcher", "Validating the local run policy..."));
|
||||
QApplication::processEvents();
|
||||
|
||||
PolicyHelper policy(appDir);
|
||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Launcher", "Update Client Rejected"),
|
||||
authFailureMessage(response,
|
||||
QCoreApplication::translate("Launcher", "The update client token is missing or invalid. Please download a valid package from the customer portal.")));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
|
||||
return -1;
|
||||
} else if (!networkOk) {
|
||||
}
|
||||
if (!policy.isApplicable(appId, channel, currentVersion))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::warning(nullptr,
|
||||
QCoreApplication::translate("Launcher", "Update Server Unavailable"),
|
||||
QCoreApplication::translate("Launcher", "Cannot connect to the update server. The installed application will be started without downloading an update."));
|
||||
progress.show();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "The local version policy is invalid: %1").arg(policy.errorString()));
|
||||
return -1;
|
||||
}
|
||||
if (policy.isExpired())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "The local version policy has expired. Connect to the network or contact an administrator."));
|
||||
return -1;
|
||||
}
|
||||
const bool currentVersionAllowed =
|
||||
policy.allowRun() && policy.isVersionAllowed(currentVersion);
|
||||
if (!currentVersionAllowed && !(networkOk && needUpdate))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Current Version Cannot Run"),
|
||||
policy.message().isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "Version %1 has been disabled by an administrator.").arg(currentVersion)
|
||||
: policy.message());
|
||||
return -1;
|
||||
}
|
||||
|
||||
LocalStateHelper state(appDir);
|
||||
if (!state.loadState())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Unable to Start"),
|
||||
QCoreApplication::translate("Launcher", "Cannot read the local state: %1").arg(state.errorString()));
|
||||
return -1;
|
||||
}
|
||||
if (state.isPolicySeqRolledBack(policy.policySeq()))
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Security Check Failed"),
|
||||
QCoreApplication::translate("Launcher", "A version policy sequence rollback was detected, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
if (state.isSystemTimeRewound())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Security Check Failed"),
|
||||
QCoreApplication::translate("Launcher", "A possible system clock rollback was detected, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (networkOk && needUpdate)
|
||||
{
|
||||
progress.close();
|
||||
const QString prompt = QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Version %1 is available. Update now?").arg(latestVer);
|
||||
const bool accepted = QMessageBox::question(nullptr,
|
||||
QCoreApplication::translate("Launcher", "New Version Available"),
|
||||
prompt,
|
||||
QMessageBox::Yes | QMessageBox::No,
|
||||
QMessageBox::No) == QMessageBox::Yes;
|
||||
if (!accepted) {
|
||||
if (!startMainApp()) {
|
||||
QMessageBox::critical(nullptr,
|
||||
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
|
||||
const bool updateRequired = policy.forceUpdate() || !currentVersionAllowed;
|
||||
const QString dialogTitle = rollbackOperation
|
||||
? QCoreApplication::translate("Launcher", "Version Rollback")
|
||||
: (updateRequired
|
||||
? QCoreApplication::translate("Launcher", "Update Required")
|
||||
: QCoreApplication::translate("Launcher", "New Version Available"));
|
||||
const QString prompt = policy.message().isEmpty()
|
||||
? (rollbackOperation
|
||||
? QCoreApplication::translate("Launcher", "An administrator selected version %1 as the rollback target. Downgrade now?")
|
||||
: QCoreApplication::translate("Launcher", "Version %1 is available. Update now?"))
|
||||
.arg(latestVer)
|
||||
: policy.message() + QCoreApplication::translate("Launcher", "\nTarget version: %1").arg(latestVer);
|
||||
bool accepted = true;
|
||||
if (updateRequired)
|
||||
{
|
||||
QMessageBox::information(nullptr, dialogTitle, prompt);
|
||||
}
|
||||
else
|
||||
{
|
||||
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
|
||||
}
|
||||
if (!accepted)
|
||||
{
|
||||
if (!startMainApp())
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Startup Failed"),
|
||||
mainStartupError.isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
|
||||
: mainStartupError);
|
||||
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const QStringList updaterArgs{
|
||||
appId,
|
||||
channel,
|
||||
latestVer,
|
||||
QStringLiteral("0"),
|
||||
QStringLiteral("--release-id=%1").arg(releaseId)
|
||||
};
|
||||
if (!QFileInfo::exists(updaterPath))
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
|
||||
QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Cannot start the updater because the executable file does not exist.\nExecutable: %1\nCheck updater_executable and install_root in the generated client configuration.")
|
||||
.arg(updaterPath));
|
||||
return -1;
|
||||
}
|
||||
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
|
||||
if (!QProcess::startDetached(updaterPath, updaterArgs))
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Updater Startup Failed"),
|
||||
QCoreApplication::translate(
|
||||
"Launcher",
|
||||
"Cannot start the updater process.\nExecutable: %1\nArguments: %2\nCheck file permissions, dependent DLLs/shared libraries, and whether the updater can run independently.")
|
||||
.arg(updaterPath, updaterArgs.join(QStringLiteral(" "))));
|
||||
QCoreApplication::translate("Launcher", "Cannot start the updater: %1").arg(updaterPath));
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
progress.setLabelText(networkOk
|
||||
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
|
||||
: QCoreApplication::translate("Launcher", "Offline startup. Starting..."));
|
||||
if (!networkOk)
|
||||
{
|
||||
progress.close();
|
||||
if (QMessageBox::question(nullptr,
|
||||
QCoreApplication::translate("Launcher", "Server Unavailable"),
|
||||
QCoreApplication::translate("Launcher", "The update server is currently unreachable. Import an offline update package?"),
|
||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes)
|
||||
{
|
||||
if (!importOfflinePackage())
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Offline Update"),
|
||||
QCoreApplication::translate("Launcher", "No offline update package was selected, or the updater could not be started."));
|
||||
return 0;
|
||||
}
|
||||
progress.show();
|
||||
}
|
||||
|
||||
if (!networkOk && !policy.isOfflineAllowed())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Network Unavailable"),
|
||||
QCoreApplication::translate("Launcher", "The update server is unreachable and the current policy does not allow offline startup."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
progress.setLabelText(
|
||||
networkOk ? QCoreApplication::translate("Launcher", "The current version is up to date. Starting...")
|
||||
: QCoreApplication::translate("Launcher", "Offline mode is active. Starting..."));
|
||||
QApplication::processEvents();
|
||||
if (!startMainApp())
|
||||
{
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr,
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Launcher", "Startup Failed"),
|
||||
mainStartupError.isEmpty()
|
||||
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
|
||||
: mainStartupError);
|
||||
QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath));
|
||||
return -1;
|
||||
}
|
||||
progress.close();
|
||||
|
||||
+14
-19
@@ -1,23 +1,18 @@
|
||||
project(MainApp)
|
||||
add_executable(MainApp
|
||||
set(_main_app_sources
|
||||
main.cpp
|
||||
MainWindow.h
|
||||
MainWindow.cpp
|
||||
../Common/ConfigHelper.h
|
||||
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||
)
|
||||
target_include_directories(MainApp
|
||||
PUBLIC ${CMAKE_SOURCE_DIR}/Common
|
||||
PRIVATE ${OPENSSL_INC}
|
||||
MainWindow.cpp
|
||||
)
|
||||
target_link_libraries(MainApp PRIVATE Common Qt5::Core Qt5::Gui Qt5::Widgets)
|
||||
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET MainApp POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
|
||||
)
|
||||
endif()
|
||||
add_executable(MainApp ${_main_app_sources})
|
||||
target_compile_features(MainApp PRIVATE cxx_std_17)
|
||||
target_link_libraries(MainApp
|
||||
PRIVATE
|
||||
Common
|
||||
Qt5::Core
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
update_client_link_embedded_resources(MainApp)
|
||||
update_client_deploy_openssl_runtime(MainApp)
|
||||
update_client_enable_qt_deployment(MainApp)
|
||||
|
||||
+41
-38
@@ -1,66 +1,69 @@
|
||||
#include "MainWindow.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QFont>
|
||||
#include <QFile>
|
||||
#include <QLabel>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include "../Common/PolicyHelper.h"
|
||||
#include "../Common/ConfigHelper.h"
|
||||
|
||||
namespace {
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
QString readDllVersion(const QString& dllPath)
|
||||
static QString readDllVersion(const QString& dllPath)
|
||||
{
|
||||
QFile file(dllPath);
|
||||
if (!file.exists())
|
||||
return QStringLiteral("missing");
|
||||
return QCoreApplication::translate("MainWindow", "missing");
|
||||
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return QStringLiteral("unreadable");
|
||||
return QCoreApplication::translate("MainWindow", "unreadable");
|
||||
|
||||
const QByteArray data = file.read(1024);
|
||||
QByteArray data = file.read(1024);
|
||||
file.close();
|
||||
return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
MainWindow::MainWindow(QWidget* parent)
|
||||
: QWidget(parent)
|
||||
{
|
||||
this->setWindowTitle("SimCAE Demo MainApp");
|
||||
this->setWindowTitle(tr("Marsco Demo MainApp"));
|
||||
this->resize(600, 400);
|
||||
|
||||
const QString appVersion = configValue(QStringLiteral("current_version"), QStringLiteral("unknown"));
|
||||
const QString product = configValue(QStringLiteral("product_code"),
|
||||
configValue(QStringLiteral("app_id"), QStringLiteral("unknown")));
|
||||
const QString channel = configValue(QStringLiteral("channel"), QStringLiteral("stable"));
|
||||
const QString apiBaseUrl = configValue(QStringLiteral("api_base_url"), QStringLiteral("not configured"));
|
||||
const QString tokenState = configValue(QStringLiteral("client_token")).isEmpty()
|
||||
? QStringLiteral("missing")
|
||||
: QStringLiteral("configured");
|
||||
const QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
|
||||
QString appVersion = ConfigHelper::instance().getValue("App", "current_version");
|
||||
if (appVersion.isEmpty())
|
||||
appVersion = tr("unknown");
|
||||
|
||||
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
|
||||
|
||||
PolicyHelper policy(QApplication::applicationDirPath());
|
||||
QString policyResult;
|
||||
if (!policy.loadPolicy("config/version_policy.dat"))
|
||||
{
|
||||
policyResult = tr("policy missing");
|
||||
}
|
||||
else if (!policy.isValid())
|
||||
{
|
||||
policyResult = tr("policy invalid");
|
||||
}
|
||||
else if (!policy.isVersionAllowed(appVersion))
|
||||
{
|
||||
policyResult = tr("version disabled");
|
||||
}
|
||||
else if (policy.isExpired())
|
||||
{
|
||||
policyResult = tr("policy expired");
|
||||
}
|
||||
else
|
||||
{
|
||||
policyResult = tr("policy ok");
|
||||
}
|
||||
|
||||
QVBoxLayout* layout = new QVBoxLayout(this);
|
||||
QLabel* label = new QLabel(QString(
|
||||
"Software Running Successfully\n"
|
||||
"Product: %1\n"
|
||||
"Version: %2\n"
|
||||
"Channel: %3\n"
|
||||
"Server: %4\n"
|
||||
"Update Client Token: %5\n"
|
||||
"DLL Version: %6")
|
||||
.arg(product, appVersion, channel, apiBaseUrl, tokenState, dllVersion));
|
||||
QLabel* label = new QLabel(tr("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
|
||||
.arg(appVersion)
|
||||
.arg(dllVersion)
|
||||
.arg(policyResult));
|
||||
QFont font = label->font();
|
||||
font.setPointSize(13);
|
||||
font.setPointSize(14);
|
||||
label->setFont(font);
|
||||
label->setAlignment(Qt::AlignCenter);
|
||||
|
||||
|
||||
+111
-64
@@ -1,96 +1,144 @@
|
||||
#include <QApplication>
|
||||
#include <QDebug>
|
||||
#include <QMessageBox>
|
||||
#include <QDir>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QSaveFile>
|
||||
#include <QTranslator>
|
||||
|
||||
#include "MainWindow.h"
|
||||
#include "../Common/ConfigHelper.h"
|
||||
#include "../Common/IntegrityHelper.h"
|
||||
#include "../Common/PolicyHelper.h"
|
||||
#include "../Common/LocalStateHelper.h"
|
||||
#include "../Common/TicketHelper.h"
|
||||
|
||||
namespace {
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
QString productCode()
|
||||
{
|
||||
return configValue(QStringLiteral("product_code"),
|
||||
configValue(QStringLiteral("app_id")));
|
||||
}
|
||||
|
||||
bool configFlag(const QString& key)
|
||||
{
|
||||
const QString value = configValue(key).toLower();
|
||||
return value == QStringLiteral("true")
|
||||
|| value == QStringLiteral("1")
|
||||
|| value == QStringLiteral("yes")
|
||||
|| value == QStringLiteral("on");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
#include "../Common/IntegrityHelper.h"
|
||||
#include "../Common/DeviceIdentityHelper.h"
|
||||
#include "../Common/TranslationHelper.h"
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
QApplication a(argc, argv);
|
||||
QTranslator translator;
|
||||
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
|
||||
a.installTranslator(&translator);
|
||||
|
||||
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
|
||||
if (elevatedWriteExitCode >= 0)
|
||||
return elevatedWriteExitCode;
|
||||
QApplication::setApplicationName("Marsco MainApp");
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
qDebug() << Qt::endl << "entered main app" << Qt::endl;
|
||||
|
||||
ConfigHelper& config = ConfigHelper::instance();
|
||||
QString launcherExecutable = config.getValue(QStringLiteral("Runtime"), QStringLiteral("launcher_executable")).trimmed();
|
||||
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, QStringLiteral("Launcher"));
|
||||
|
||||
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
|
||||
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
|
||||
QString ticketFilePath;
|
||||
QString healthFilePath;
|
||||
for (int i = 1; i < argc; ++i)
|
||||
{
|
||||
const QString arg(argv[i]);
|
||||
if (arg.startsWith(QStringLiteral("--ticket-file=")))
|
||||
ticketFilePath = arg.mid(QStringLiteral("--ticket-file=").size());
|
||||
else if (arg.startsWith(QStringLiteral("--health-file=")))
|
||||
healthFilePath = arg.mid(QStringLiteral("--health-file=").size());
|
||||
if (arg.startsWith("--ticket-file="))
|
||||
ticketFilePath = arg.mid(QString("--ticket-file=").size());
|
||||
else if (arg.startsWith("--health-file="))
|
||||
healthFilePath = arg.mid(QString("--health-file=").size());
|
||||
}
|
||||
|
||||
QString ticketError;
|
||||
if (ticketFilePath.isEmpty()
|
||||
|| !TicketHelper::consumeAndVerify(ticketFilePath,
|
||||
productCode(), config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")),
|
||||
config.getValue(QStringLiteral("App"), QStringLiteral("current_version")),
|
||||
config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")),
|
||||
config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
|
||||
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
|
||||
&ticketError))
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Startup Restriction",
|
||||
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.")
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Startup Restriction"),
|
||||
QCoreApplication::translate("MainApp", "Invalid or missing one-time launch ticket: %1\nPlease use %2.")
|
||||
.arg(ticketError, launcherExecutable));
|
||||
return -1;
|
||||
}
|
||||
|
||||
QString appDir = QApplication::applicationDirPath();
|
||||
const QString installRoot = config.installRoot();
|
||||
if (configFlag(QStringLiteral("verify_installed_on_start"))) {
|
||||
IntegrityHelper integrity(installRoot);
|
||||
if (!integrity.verifyInstalledVersion(
|
||||
productCode(),
|
||||
config.getValue(QStringLiteral("App"), QStringLiteral("channel")),
|
||||
config.getValue(QStringLiteral("App"), QStringLiteral("current_version"))))
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Integrity Check Failed",
|
||||
QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1")
|
||||
.arg(integrity.errorString()));
|
||||
return -1;
|
||||
}
|
||||
DeviceIdentityHelper identity(appDir);
|
||||
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "License Error"),
|
||||
QCoreApplication::translate("MainApp", "Local license invalid: %1").arg(identity.errorString()));
|
||||
return -1;
|
||||
}
|
||||
PolicyHelper policy(appDir);
|
||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "Local policy invalid: %1").arg(policy.errorString()));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (policy.isExpired())
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "Policy expired. The application cannot start."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
const QString currentVersion = config.getValue("App", "current_version");
|
||||
if (!policy.isApplicable(config.getValue("App", "app_id"),
|
||||
config.getValue("App", "channel"),
|
||||
currentVersion)
|
||||
|| !policy.allowRun() || !policy.isVersionAllowed(currentVersion))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate(
|
||||
"MainApp",
|
||||
"The signed policy does not allow this installed version to run."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
LocalStateHelper state(appDir);
|
||||
if (!state.loadState())
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "State Error"),
|
||||
QCoreApplication::translate("MainApp", "Cannot load local state: %1").arg(state.errorString()));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (state.isPolicySeqRolledBack(policy.policySeq()))
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "A policy sequence rollback was detected, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (state.isSystemTimeRewound())
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Policy Error"),
|
||||
QCoreApplication::translate("MainApp", "The system clock appears to have moved backward, so startup was blocked."));
|
||||
return -1;
|
||||
}
|
||||
|
||||
IntegrityHelper integrity(installRoot);
|
||||
if (!integrity.verifyInstalledVersion(
|
||||
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
||||
currentVersion))
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Integrity Check Failed"),
|
||||
QCoreApplication::translate("MainApp", "Application files failed signed manifest verification:\n%1")
|
||||
.arg(integrity.errorString()));
|
||||
return -1;
|
||||
}
|
||||
|
||||
MainWindow w;
|
||||
w.show();
|
||||
|
||||
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
|
||||
if (!state.saveState())
|
||||
{
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "State Error"),
|
||||
QCoreApplication::translate("MainApp", "Cannot save local state: %1").arg(state.errorString()));
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!healthFilePath.isEmpty())
|
||||
@@ -102,14 +150,13 @@ int main(int argc, char* argv[])
|
||||
|| healthFile.write(healthy) != healthy.size()
|
||||
|| !healthFile.commit())
|
||||
{
|
||||
QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file.");
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("MainApp", "Startup Error"),
|
||||
QCoreApplication::translate("MainApp", "Cannot write the update health confirmation file."));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
MainWindow w;
|
||||
w.show();
|
||||
|
||||
qDebug() << "Main program MainApp is running normally";
|
||||
return a.exec();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# SimCAE 更新客户端
|
||||
|
||||
`update-client` 提供了 SimCAE 使用的受控启动和更新运行时。它是作为父 SimCAE 项目的一部分构建的,并不是一个独立的最终用户交付产品。
|
||||
|
||||
## 发布不变量
|
||||
|
||||
* 每个受支持的发布版本构建都通过 `Launcher` 启动;直接启动应用程序必须被拒绝。
|
||||
* 调试构建可能允许本地开发的直接启动,但它们绝不能打包给用户使用。
|
||||
* 产品配置在配置时选择并嵌入到二进制文件中。安装的 `app_config.json` 不是支持的配置机制。
|
||||
* 可变的机器状态与产品策略分开保存,并不是授权的来源。
|
||||
* 由服务器签名的策略、清单、设备凭证以及更新负载验证始终保持权威性。
|
||||
* 用户可见的源文本是英文。其他语言仅通过本地化资源提供。
|
||||
* 父级 `package_installer` 目标是唯一支持的最终用户包路径。 `package_installer_qt` 已归档且不被支持。
|
||||
|
||||
## 运行时组件
|
||||
|
||||
| 组件 | 责任 |
|
||||
| --- | --- |
|
||||
| 启动器 | 验证启动策略、设备状态和已安装文件;检查更新;创建一次性启动凭证。 |
|
||||
| 更新器 | 下载并验证更改的文件,管理更新事务,需要时请求引导替换,并提交或回滚。 |
|
||||
| Bootstrap | 替换在更新过程中无法更新的文件,然后继续事务。 |
|
||||
| 主应用 | 集成示例用于启动票的消费和更新健康报告。SimCAE 在其可执行文件中实现了相同的契约。 |
|
||||
| 公共 | 共享产品配置、状态、HTTP、身份、策略、完整性以及票务支持。 |
|
||||
|
||||
## 支持的流程
|
||||
|
||||
从父级 SimCAE 仓库中进行配置、构建、测试和打包。正常的发布流程是:
|
||||
|
||||
```powershell
|
||||
git submodule update --init --recursive
|
||||
cmake --preset SimCAE-release
|
||||
cmake --build --preset SimCAE-release --target SimCAE
|
||||
ctest --test-dir out/build/SimCAE-release -C Release --output-on-failure
|
||||
cmake --build --preset SimCAE-release --target package_installer
|
||||
```
|
||||
|
||||
不要使用已移除的 PowerShell SDK/客户端打包脚本。它们绕过了父安装规则,且不再代表交付的产品。
|
||||
|
||||
请参见 [构建和打包](docs/build-and-packaging.md) 了解前提条件、独立开发者构建、包检查和输出所有权信息。
|
||||
|
||||
## 配置
|
||||
|
||||
配置时的源文件选择顺序如下:
|
||||
|
||||
1. `config/config.json`,如果存在的话。
|
||||
2. `config/app_config.example.json`,否则。
|
||||
|
||||
所选数据将被验证并嵌入。源文件不会被复制到安装目录中。安装目录中名为 `app_config.json` 或 `client.ini` 的文件仅为遗留输入,不得覆盖嵌入的策略。
|
||||
|
||||
查看 [配置和安全](docs/configuration-and-security.md) 和 [旧配置迁移](docs/legacy-configuration-migration.md) 。
|
||||
|
||||
## 文档说明
|
||||
|
||||
* [架构](docs/architecture.md)
|
||||
* [构建和打包](docs/build-and-packaging.md)
|
||||
* [配置和安全](docs/configuration-and-security.md)
|
||||
* [本地化维护](docs/i18n.md)
|
||||
* [旧配置迁移](docs/legacy-configuration-migration.md)
|
||||
|
||||
## 仓库规则
|
||||
|
||||
* 保持生产环境的 C、C++和 CMake 文件中不含汉字。添加或更新本地化 UI 文本的翻译资源。
|
||||
* 通过 CMake 导入目标链接 Qt、OpenSSL 和其他依赖项。绝不要将开发机器的路径添加到仓库中。
|
||||
* 将发布打包保留在父项目中。此子项目可能会暴露目标和安装规则,但不会生成竞争的安装程序。
|
||||
* 不要将嵌入资源、本地设置或客户端令牌视为可以抵御机器管理员的机密信息。
|
||||
* 没有可用启动器/更新运行时的平台必须导致发布配置或打包失败。它不得回退到直接启动。
|
||||
|
||||
## 仓库布局
|
||||
|
||||
```text
|
||||
Bootstrap/ 更新交接可执行文件
|
||||
Common/ 共享运行时服务
|
||||
Launcher/ 必要的产品入口点
|
||||
MainApp/ 集成示例
|
||||
Updater/ 事务性更新可执行文件
|
||||
config/ 配置时模板和公共验证材料
|
||||
docs/ 保持的架构和操作文档
|
||||
```
|
||||
+19
-37
@@ -1,42 +1,24 @@
|
||||
if(WIN32 AND MSVC)
|
||||
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
||||
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
||||
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
||||
# 关闭VS自动Win32设计时预生成
|
||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
||||
endif()
|
||||
|
||||
project(Updater)
|
||||
set(SRC
|
||||
set(_updater_sources
|
||||
main.cpp
|
||||
UpdaterLogic.h
|
||||
UpdaterLogic.cpp
|
||||
UpdateTransaction.h
|
||||
UpdateTransaction.cpp
|
||||
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||
)
|
||||
add_executable(Updater ${SRC})
|
||||
UpdateTransaction.h
|
||||
UpdateTransaction.cpp
|
||||
)
|
||||
|
||||
add_executable(Updater ${_updater_sources})
|
||||
target_compile_features(Updater PRIVATE cxx_std_17)
|
||||
target_link_libraries(Updater
|
||||
PRIVATE
|
||||
Common
|
||||
UpdateClientUpdaterLogic
|
||||
Qt5::Core
|
||||
Qt5::Network
|
||||
Qt5::Gui
|
||||
Qt5::Widgets
|
||||
)
|
||||
update_client_link_embedded_resources(Updater)
|
||||
update_client_deploy_openssl_runtime(Updater)
|
||||
update_client_enable_qt_deployment(Updater)
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
|
||||
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
|
||||
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
|
||||
|
||||
# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递
|
||||
target_link_libraries(Updater PRIVATE
|
||||
Common
|
||||
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
|
||||
)
|
||||
|
||||
# Qt部署脚本
|
||||
if(WIN32)
|
||||
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
|
||||
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
|
||||
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
|
||||
add_custom_command(TARGET Updater POST_BUILD
|
||||
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
|
||||
)
|
||||
endif()
|
||||
|
||||
@@ -37,8 +37,6 @@ bool UpdateTransaction::copyOverwrite(const QString& source, const QString& dest
|
||||
|
||||
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;
|
||||
@@ -150,8 +148,6 @@ bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
|
||||
|
||||
bool UpdateTransaction::backupCurrentFiles()
|
||||
{
|
||||
// 替换前先备份所有将被修改或删除的文件。
|
||||
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
|
||||
if (!writeState("waiting_mainapp_exit")) return false;
|
||||
QStringList paths = m_changedPaths;
|
||||
paths.append(m_obsoletePaths);
|
||||
@@ -167,8 +163,6 @@ bool UpdateTransaction::backupCurrentFiles()
|
||||
|
||||
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);
|
||||
@@ -232,7 +226,6 @@ QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePat
|
||||
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");
|
||||
|
||||
+396
-401
File diff suppressed because it is too large
Load Diff
+6
-10
@@ -24,15 +24,13 @@ class UpdaterLogic : public QObject
|
||||
public:
|
||||
explicit UpdaterLogic(QObject* parent = nullptr);
|
||||
|
||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer,
|
||||
int versionId, const QString& releaseId = QString());
|
||||
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
|
||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
||||
bool verifyManifestSignature(const QString& publicKeyPath = ":/simcae/update-client/manifest-public-key.pem") const;
|
||||
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
||||
bool saveManifestCache(const QString& cacheDir) const;
|
||||
bool loadManifestCache(const QString& cacheDir, const QString& version);
|
||||
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
|
||||
QString offlineError() const { return m_offlineError; }
|
||||
QString errorString() const { return m_error; }
|
||||
|
||||
void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
||||
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
|
||||
@@ -55,6 +53,9 @@ signals:
|
||||
private:
|
||||
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
|
||||
qint64 expectedSize, const QString& resumePartPath);
|
||||
bool validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
|
||||
const QString& channel, const QString& targetVer,
|
||||
int versionId, QList<FileDownloadItem>* fileItems) const;
|
||||
bool isSafeRelativePath(const QString& path) const;
|
||||
bool isRuntimeProtectedPath(const QString& path) const;
|
||||
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
|
||||
@@ -64,18 +65,13 @@ private:
|
||||
QString m_serverAddr;
|
||||
QJsonObject m_manifest;
|
||||
QString m_manifestText;
|
||||
QString m_manifestSha256;
|
||||
QString m_manifestSignature;
|
||||
QString m_manifestSignatureAlg;
|
||||
QString m_manifestKeyId;
|
||||
bool m_manifestSigned = false;
|
||||
QList<FileDownloadItem> m_fileItems;
|
||||
mutable bool m_manifestSignatureVerified = false;
|
||||
bool m_downloadAllOk = false;
|
||||
qint64 m_downloadTotalBytes = 0;
|
||||
qint64 m_downloadCompletedBytes = 0;
|
||||
qint64 m_sessionDownloadedBytes = 0;
|
||||
QString m_currentDownloadPath;
|
||||
QString m_offlineError;
|
||||
mutable QString m_error;
|
||||
QElapsedTimer m_downloadTimer;
|
||||
};
|
||||
|
||||
+185
-195
@@ -1,51 +1,43 @@
|
||||
#include <QApplication>
|
||||
#include <QApplication>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QElapsedTimer>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QProcess>
|
||||
#include <QProgressDialog>
|
||||
#include <QSaveFile>
|
||||
#include <QStorageInfo>
|
||||
#include <QThread>
|
||||
#include <QTranslator>
|
||||
#include "UpdaterLogic.h"
|
||||
#include "UpdateTransaction.h"
|
||||
#include "FileHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include "TicketHelper.h"
|
||||
#include "PolicyHelper.h"
|
||||
#include "TranslationHelper.h"
|
||||
#include <QVersionNumber>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
QApplication::setApplicationName("SimCAE Updater");
|
||||
QTranslator translator;
|
||||
if (translator.load(":/i18n/update-client_zh_CN.qm"))
|
||||
app.installTranslator(&translator);
|
||||
QApplication::setApplicationName("Marsco Updater");
|
||||
|
||||
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
|
||||
if (elevatedWriteExitCode >= 0)
|
||||
return elevatedWriteExitCode;
|
||||
TranslationHelper translation;
|
||||
translation.installForSystemLocale();
|
||||
|
||||
UpdaterLogic logic;
|
||||
QString offlinePackagePath;
|
||||
QString appId;
|
||||
QString channel;
|
||||
QString targetVersion;
|
||||
QString releaseId;
|
||||
int targetVersionId = 0;
|
||||
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
|
||||
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath)) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Updater", "Invalid Offline Package"),
|
||||
logic.offlineError());
|
||||
QMessageBox::critical(nullptr, QCoreApplication::translate("Updater", "Invalid Offline Package"), logic.offlineError());
|
||||
return -1;
|
||||
}
|
||||
const QJsonObject packageManifest = logic.getManifest();
|
||||
@@ -55,9 +47,10 @@ int main(int argc, char* argv[])
|
||||
targetVersionId = packageManifest.value("manifest_seq").toInt();
|
||||
} else {
|
||||
if (argc < 5) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Updater", "Updater Argument Error"),
|
||||
QCoreApplication::translate("Updater", "The updater is missing online update arguments or an offline update package."));
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Invalid Updater Arguments"),
|
||||
QCoreApplication::translate("Updater", "The updater requires online update arguments or an offline update package."));
|
||||
return -1;
|
||||
}
|
||||
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
|
||||
@@ -67,8 +60,6 @@ int main(int argc, char* argv[])
|
||||
const QString arg = argv[i];
|
||||
if (arg.startsWith("--bootstrap-resume="))
|
||||
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
||||
else if (arg.startsWith("--release-id="))
|
||||
releaseId = arg.mid(QString("--release-id=").size());
|
||||
}
|
||||
const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
||||
const QString runtimeDir = QApplication::applicationDirPath();
|
||||
@@ -77,36 +68,40 @@ int main(int argc, char* argv[])
|
||||
const QString targetDir = config.installRoot();
|
||||
const QString updateDir = config.updateRoot();
|
||||
QDir().mkpath(updateDir);
|
||||
QString configuredProductCode = config.getValue("App", "product_code").trimmed();
|
||||
if (configuredProductCode.isEmpty())
|
||||
configuredProductCode = config.getValue("App", "app_id").trimmed();
|
||||
QString configuredChannel = config.getValue("App", "channel").trimmed();
|
||||
if (configuredChannel.isEmpty())
|
||||
configuredChannel = QStringLiteral("stable");
|
||||
if (appId != configuredProductCode || channel != configuredChannel) {
|
||||
QMessageBox::critical(nullptr,
|
||||
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
|
||||
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
|
||||
QCoreApplication::translate("Updater", "The update package application or channel does not match this installation."));
|
||||
return -1;
|
||||
}
|
||||
QString fromVersion = config.getValue("App", "current_version");
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
PolicyHelper offlinePolicy(runtimeDir);
|
||||
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
||||
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Updater", "Offline Update Rejected"),
|
||||
QCoreApplication::translate("Updater", "The local signed policy has expired, forbids offline updates, or does not allow the target version."));
|
||||
return -1;
|
||||
}
|
||||
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
||||
QVersionNumber::fromString(fromVersion)) < 0
|
||||
&& !offlinePolicy.allowRollback()) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QCoreApplication::translate("Updater", "Downgrade Forbidden"),
|
||||
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an offline package with a lower version."));
|
||||
return -1;
|
||||
}
|
||||
PolicyHelper updatePolicy(runtimeDir);
|
||||
const bool policyApplicable =
|
||||
updatePolicy.loadPolicy() && updatePolicy.isValid()
|
||||
&& updatePolicy.isApplicable(appId, channel, fromVersion)
|
||||
&& !updatePolicy.isExpired()
|
||||
&& updatePolicy.isVersionAllowed(targetVersion);
|
||||
if (!policyApplicable
|
||||
|| (!offlinePackagePath.isEmpty() && !updatePolicy.isOfflineAllowed()))
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Denied"),
|
||||
QCoreApplication::translate(
|
||||
"Updater",
|
||||
"The local signed policy is invalid, expired, not applicable to this installation, or does not allow the requested update."));
|
||||
return -1;
|
||||
}
|
||||
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
||||
QVersionNumber::fromString(fromVersion)) < 0
|
||||
&& !updatePolicy.allowRollback())
|
||||
{
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Downgrade Prohibited"),
|
||||
QCoreApplication::translate("Updater", "The current signed policy does not allow installing an older version."));
|
||||
return -1;
|
||||
}
|
||||
QString deviceId = config.getValue("Update", "device_id");
|
||||
if (deviceId.isEmpty()) deviceId = "unknown_device";
|
||||
@@ -115,24 +110,26 @@ int main(int argc, char* argv[])
|
||||
QString restoredVersion;
|
||||
QString recoveryError;
|
||||
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Recovery Failed"),
|
||||
QCoreApplication::translate("Updater", "An unfinished update was detected, but the old version could not be restored: %1\nDo not continue running the software. Please contact the administrator.")
|
||||
QCoreApplication::translate("Updater", "The previous update did not finish, and the old version could not be restored: %1\nDo not continue running the software. Contact an administrator.")
|
||||
.arg(recoveryError));
|
||||
return -1;
|
||||
}
|
||||
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
|
||||
if (!config.setValue("App", "current_version", restoredVersion)) {
|
||||
QMessageBox::critical(nullptr,
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Recovery Failed"),
|
||||
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Please check write permissions for the configuration directory."));
|
||||
QCoreApplication::translate("Updater", "The old files were restored, but the version state could not be restored. Check access to the system state store."));
|
||||
return -1;
|
||||
}
|
||||
fromVersion = restoredVersion;
|
||||
}
|
||||
}
|
||||
|
||||
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing update..."), QString(), 0, 100);
|
||||
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing the update..."), QString(), 0, 100);
|
||||
progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
|
||||
progress.setCancelButton(nullptr);
|
||||
progress.setWindowModality(Qt::ApplicationModal);
|
||||
@@ -162,10 +159,10 @@ int main(int argc, char* argv[])
|
||||
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
|
||||
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
|
||||
const QString fileText = path.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Preparing download...")
|
||||
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
|
||||
? QCoreApplication::translate("Updater", "Preparing the download...")
|
||||
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
|
||||
progress.setValue(value);
|
||||
progress.setLabelText(QString("%1\n%2 / %3 · %4/s")
|
||||
progress.setLabelText(QString("%1\n%2 / %3 | %4/s")
|
||||
.arg(fileText, formatBytes(received), formatBytes(total),
|
||||
formatBytes(static_cast<qint64>(bytesPerSecond))));
|
||||
QApplication::processEvents();
|
||||
@@ -182,18 +179,13 @@ int main(int argc, char* argv[])
|
||||
QMessageBox::critical(nullptr, title, message);
|
||||
return -1;
|
||||
};
|
||||
const auto withDetails = [](const QString& message, const QString& details) {
|
||||
return details.trimmed().isEmpty()
|
||||
? message
|
||||
: message + QCoreApplication::translate("Updater", "\n\nDetails:\n%1").arg(details);
|
||||
};
|
||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||
return ConfigHelper::executableNameForCurrentPlatform(
|
||||
config.getValue("Runtime", key), fallback);
|
||||
const QString value = config.getValue("Runtime", key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
};
|
||||
const QString mainExecutable = configuredName("main_executable", "MainApp");
|
||||
const QString updaterExecutable = configuredName("updater_executable", "Updater");
|
||||
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
|
||||
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
|
||||
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
|
||||
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
|
||||
bool timeoutOk = false;
|
||||
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
|
||||
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
|
||||
@@ -201,38 +193,19 @@ int main(int argc, char* argv[])
|
||||
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
|
||||
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
|
||||
const QString launchToken = config.getValue("App", "launch_token");
|
||||
QString mainStartupError;
|
||||
const auto launchMainApp = [&](const QString& healthFile = QString()) {
|
||||
mainStartupError.clear();
|
||||
if (!QFileInfo::exists(mainAppPath)) {
|
||||
mainStartupError = QCoreApplication::translate(
|
||||
"Updater",
|
||||
"Cannot start the main application because the executable file does not exist.\nExecutable: %1\nCheck main_executable and install_root in the generated client configuration.")
|
||||
.arg(mainAppPath);
|
||||
return false;
|
||||
}
|
||||
QString ticketPath;
|
||||
QString ticketError;
|
||||
const QString launchVersion = config.getValue("App", "current_version");
|
||||
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
|
||||
&ticketPath, &ticketError)) {
|
||||
qDebug() << "Cannot create launch ticket:" << ticketError;
|
||||
mainStartupError = QCoreApplication::translate(
|
||||
"Updater",
|
||||
"Cannot start the main application because the one-time launch ticket could not be created.\nExecutable: %1\nDetails: %2")
|
||||
.arg(mainAppPath, ticketError);
|
||||
return false;
|
||||
}
|
||||
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
|
||||
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
|
||||
const bool started = QProcess::startDetached(mainAppPath, args);
|
||||
if (!started) {
|
||||
QFile::remove(ticketPath);
|
||||
mainStartupError = QCoreApplication::translate(
|
||||
"Updater",
|
||||
"Cannot start the main application process.\nExecutable: %1\nTicket file: %2\nHealth file: %3\nCheck file permissions, dependent DLLs/shared libraries, and whether the executable can run independently.")
|
||||
.arg(mainAppPath, ticketPath, healthFile.isEmpty() ? QCoreApplication::translate("Updater", "<not used>") : healthFile);
|
||||
}
|
||||
if (!started) QFile::remove(ticketPath);
|
||||
return started;
|
||||
};
|
||||
const auto bootstrapPlanFile = [&]() {
|
||||
@@ -249,7 +222,7 @@ int main(int argc, char* argv[])
|
||||
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
||||
FileHelper::killProcess(mainExecutable);
|
||||
transaction.markRollbackRequired(reason);
|
||||
progress.setLabelText(QCoreApplication::translate("Updater", "Delegating rollback to Bootstrap..."));
|
||||
progress.setLabelText(QCoreApplication::translate("Updater", "Handing rollback over to Bootstrap..."));
|
||||
QApplication::processEvents();
|
||||
if (launchBootstrap("rollback")) {
|
||||
progress.close();
|
||||
@@ -258,7 +231,7 @@ int main(int argc, char* argv[])
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr, title,
|
||||
reason + QCoreApplication::translate("Updater", "\n\nCannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator."));
|
||||
reason + QCoreApplication::translate("Updater", "\n\nBootstrap could not be started to perform the rollback. Do not continue running the software. Contact an administrator."));
|
||||
return -1;
|
||||
};
|
||||
|
||||
@@ -267,7 +240,8 @@ int main(int argc, char* argv[])
|
||||
if (!transaction.resumeExisting(&resumeError)) {
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr,
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Transaction Resume Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
|
||||
return -1;
|
||||
@@ -281,55 +255,60 @@ int main(int argc, char* argv[])
|
||||
reportUpdateResult(false);
|
||||
if (stateOk) launchMainApp();
|
||||
progress.close();
|
||||
QMessageBox::warning(nullptr,
|
||||
QMessageBox::warning(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Rolled Back"),
|
||||
stateOk
|
||||
? QCoreApplication::translate("Updater", "The new version failed to install or start. The old version has been restored and started automatically.")
|
||||
: QCoreApplication::translate("Updater", "The old files were restored, but the old version number could not be written back. Please check configuration directory permissions."));
|
||||
? QCoreApplication::translate("Updater", "The new version could not be installed or started. The old version was restored and started automatically.")
|
||||
: QCoreApplication::translate("Updater", "The old files were restored, but the old version number could not be written back. Check permissions on the system state store."));
|
||||
return stateOk ? 0 : -1;
|
||||
}
|
||||
if (bootstrapResult != "success") {
|
||||
reportUpdateResult(false);
|
||||
progress.close();
|
||||
QMessageBox::critical(nullptr,
|
||||
QMessageBox::critical(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Automatic Rollback Failed"),
|
||||
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Please contact the administrator."));
|
||||
QCoreApplication::translate("Updater", "Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator."));
|
||||
return -1;
|
||||
}
|
||||
} else if (!transaction.initialize()) {
|
||||
return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save the transaction state."),
|
||||
"transaction_init_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the update transaction directory or save its state."),
|
||||
"transaction_init_failed");
|
||||
} else if (!offlinePackagePath.isEmpty()) {
|
||||
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
|
||||
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
|
||||
return fail(QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
|
||||
"offline_marker_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Update Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the offline transaction marker."),
|
||||
"offline_marker_failed");
|
||||
}
|
||||
|
||||
setProgress(resumingFromBootstrap ? 72 : 10,
|
||||
setProgress(
|
||||
resumingFromBootstrap ? 72 : 10,
|
||||
offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Fetching and verifying version manifest...")
|
||||
: QCoreApplication::translate("Updater", "Verifying offline update package..."));
|
||||
? QCoreApplication::translate("Updater", "Fetching and validating the version manifest...")
|
||||
: QCoreApplication::translate("Updater", "Validating the offline update package..."));
|
||||
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
|
||||
if (resumingFromBootstrap) {
|
||||
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
|
||||
logic.errorString()));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "The signed manifest cache could not be read after Bootstrap installation."));
|
||||
} else if (offlinePackagePath.isEmpty()) {
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId);
|
||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
||||
}
|
||||
if (!logic.verifyManifestSignature()) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "Cannot reverify the manifest signature after Bootstrap installation."),
|
||||
logic.errorString()));
|
||||
return fail(QCoreApplication::translate("Updater", "Security Verification Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Please contact the administrator."),
|
||||
logic.errorString()),
|
||||
"manifest_signature_invalid");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Security Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The version manifest signature could not be revalidated after Bootstrap installation."));
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Security Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The version manifest signature is invalid. The update has stopped. Contact an administrator."),
|
||||
"manifest_signature_invalid");
|
||||
}
|
||||
QStringList obsoletePaths;
|
||||
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
||||
@@ -345,67 +324,68 @@ int main(int argc, char* argv[])
|
||||
}
|
||||
if (!logic.saveManifestCache(manifestCacheDir)) {
|
||||
if (resumingFromBootstrap)
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."),
|
||||
logic.errorString()));
|
||||
return fail(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
|
||||
logic.errorString()),
|
||||
"manifest_cache_failed");
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache."));
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the new version manifest cache. The update has stopped."),
|
||||
"manifest_cache_failed");
|
||||
}
|
||||
|
||||
if (!resumingFromBootstrap) {
|
||||
setProgress(25,
|
||||
setProgress(
|
||||
25,
|
||||
offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
|
||||
: QCoreApplication::translate("Updater", "Preparing offline package files..."));
|
||||
if (offlinePackagePath.isEmpty())
|
||||
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||
if (logic.getFileList().isEmpty())
|
||||
return fail(QCoreApplication::translate("Updater", "No Files to Update"),
|
||||
withDetails(QCoreApplication::translate("Updater", "The server did not return any version files. The update has stopped."),
|
||||
logic.errorString()),
|
||||
"empty_file_list");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "No Update Files"),
|
||||
QCoreApplication::translate("Updater", "The server returned no version files. The update has stopped."),
|
||||
"empty_file_list");
|
||||
|
||||
QStorageInfo storage(updateDir);
|
||||
storage.refresh();
|
||||
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
||||
const qint64 availableBytes = storage.bytesAvailable();
|
||||
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
|
||||
return fail(QCoreApplication::translate("Updater", "Insufficient Disk Space"),
|
||||
QCoreApplication::translate("Updater",
|
||||
"The update requires at least %1 of free space, but the installation drive currently has only %2.\n"
|
||||
"The required space includes downloaded files, old version backups, and a safety margin.")
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Insufficient Disk Space"),
|
||||
QCoreApplication::translate("Updater", "The update requires at least %1 of free space, but the installation drive has only %2 available.\nThe required space includes downloaded files, the old-version backup, and a safety margin.")
|
||||
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
|
||||
"disk_space_insufficient");
|
||||
}
|
||||
|
||||
const QString stagingDir = transaction.stagingDir();
|
||||
setProgress(30, offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Downloading and verifying %1 version files...").arg(logic.getFileList().size())
|
||||
: QCoreApplication::translate("Updater", "Extracting and verifying %1 offline files...").arg(logic.getFileList().size()));
|
||||
setProgress(
|
||||
30,
|
||||
(offlinePackagePath.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "Downloading and validating %1 version files...")
|
||||
: QCoreApplication::translate("Updater", "Extracting and validating %1 offline files..."))
|
||||
.arg(logic.getFileList().size()));
|
||||
if (!offlinePackagePath.isEmpty()) {
|
||||
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
|
||||
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"),
|
||||
logic.offlineError(),
|
||||
"offline_package_invalid");
|
||||
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"), logic.offlineError(), "offline_package_invalid");
|
||||
} else {
|
||||
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
|
||||
logic.reportDownloadResult(appId, channel, targetVersion, false);
|
||||
return fail(QCoreApplication::translate("Updater", "Download Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "Some files failed to download or failed SHA-256 verification. Please check the network, update cache, or server release files and try again."),
|
||||
logic.errorString()),
|
||||
"download_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Download Failed"),
|
||||
QCoreApplication::translate("Updater", "Some files could not be downloaded or failed SHA-256 validation. Check the network and try again."),
|
||||
"download_failed");
|
||||
}
|
||||
logic.reportDownloadResult(appId, channel, targetVersion, true);
|
||||
}
|
||||
|
||||
setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files..."));
|
||||
setProgress(58, QCoreApplication::translate("Updater", "Validating the complete version files..."));
|
||||
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
||||
return fail(QCoreApplication::translate("Updater", "File Verification Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "The downloaded/staged files do not match the target version manifest. The update has stopped before replacing installed files."),
|
||||
logic.errorString()),
|
||||
"staging_verify_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "File Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The staged files do not match the version manifest. The update has stopped."),
|
||||
"staging_verify_failed");
|
||||
|
||||
QStringList changedPaths;
|
||||
QDir stagingRoot(stagingDir);
|
||||
@@ -417,35 +397,39 @@ int main(int argc, char* argv[])
|
||||
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
|
||||
for (const QString& path : changedPaths) {
|
||||
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
||||
return fail(QCoreApplication::translate("Updater", "Bootstrap Cannot Self-Update"),
|
||||
QCoreApplication::translate("Updater", "This version contains a new %1. Please upgrade this component with the installer, then publish the business version again.")
|
||||
.arg(bootstrapManifestPath),
|
||||
"bootstrap_self_update_blocked");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Bootstrap Cannot Update Itself"),
|
||||
QCoreApplication::translate("Updater", "This version contains a new %1. Upgrade this component with the installer, then republish the application version.").arg(bootstrapManifestPath),
|
||||
"bootstrap_self_update_blocked");
|
||||
}
|
||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||
return fail(QCoreApplication::translate("Updater", "Transaction Recording Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the list of verified or pending deletion files. The update has stopped."),
|
||||
"transaction_record_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Transaction Recording Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the validated-file or pending-deletion lists. The update has stopped."),
|
||||
"transaction_record_failed");
|
||||
|
||||
setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
|
||||
if (!FileHelper::killProcess(mainExecutable))
|
||||
return fail(QCoreApplication::translate("Updater", "Cannot Close Main Application"),
|
||||
QCoreApplication::translate("Updater", "%1 is still running. Please close it manually and try again.").arg(mainExecutable),
|
||||
"mainapp_close_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Cannot Close Main Application"),
|
||||
QCoreApplication::translate("Updater", "%1 is still running. Close it manually and try again.").arg(mainExecutable),
|
||||
"mainapp_close_failed");
|
||||
|
||||
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 files to be deleted...")
|
||||
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 deletions...")
|
||||
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
||||
if (!transaction.backupCurrentFiles())
|
||||
return fail(QCoreApplication::translate("Updater", "Backup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot back up current version files. The new version has not been installed. Please check disk space and directory permissions."),
|
||||
"backup_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Backup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot back up the current version files. The new version has not been installed. Check disk space and directory permissions."),
|
||||
"backup_failed");
|
||||
|
||||
const QString planFile = bootstrapPlanFile();
|
||||
QSaveFile plan(planFile);
|
||||
if (!plan.open(QIODevice::WriteOnly))
|
||||
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
|
||||
"bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
|
||||
"bootstrap_plan_failed");
|
||||
const auto writePlanItem = [&](char operation, const QString& path) {
|
||||
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
|
||||
return plan.write(line) == line.size();
|
||||
@@ -453,58 +437,63 @@ int main(int argc, char* argv[])
|
||||
for (const QString& path : changedPaths) {
|
||||
if (!writePlanItem('C', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
|
||||
"bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
|
||||
"bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
for (const QString& path : obsoletePaths) {
|
||||
if (!writePlanItem('D', path)) {
|
||||
plan.cancelWriting();
|
||||
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
|
||||
"bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
|
||||
"bootstrap_plan_failed");
|
||||
}
|
||||
}
|
||||
if (!plan.commit() || !transaction.markAwaitingBootstrap())
|
||||
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
|
||||
"bootstrap_plan_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Handoff Preparation Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
|
||||
"bootstrap_plan_failed");
|
||||
|
||||
setProgress(78, QCoreApplication::translate("Updater", "Delegating installation to Bootstrap..."));
|
||||
setProgress(78, QCoreApplication::translate("Updater", "Handing installation over to Bootstrap..."));
|
||||
if (!launchBootstrap("install"))
|
||||
return fail(QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot start the standalone update handoff program: %1").arg(bootstrapPath),
|
||||
"bootstrap_start_failed");
|
||||
return fail(
|
||||
QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot start the independent update handoff program: %1").arg(bootstrapPath),
|
||||
"bootstrap_start_failed");
|
||||
progress.close();
|
||||
return 0;
|
||||
}
|
||||
|
||||
setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result..."));
|
||||
setProgress(82, QCoreApplication::translate("Updater", "Validating the Bootstrap installation result..."));
|
||||
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Installation Verification Failed"),
|
||||
withDetails(QCoreApplication::translate("Updater", "New version files failed verification after installation. The updater will roll back to the previous version."),
|
||||
logic.errorString()));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Installation Validation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version files failed validation after installation."));
|
||||
for (const QString& path : transaction.obsoletePaths()) {
|
||||
if (QFile::exists(QDir(targetDir).filePath(path)))
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
|
||||
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Obsolete File Cleanup Failed"),
|
||||
QCoreApplication::translate("Updater", "An obsolete file still exists: %1").arg(path));
|
||||
}
|
||||
|
||||
setProgress(89, QCoreApplication::translate("Updater", "Saving new version state..."));
|
||||
setProgress(89, QCoreApplication::translate("Updater", "Saving the new version state..."));
|
||||
if (!config.setValue("App", "current_version", targetVersion)
|
||||
|| config.getValue("App", "current_version") != targetVersion)
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "State Save Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the current version number."));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "State Save Failed"),
|
||||
QCoreApplication::translate("Updater", "Cannot save the current version number."));
|
||||
|
||||
const QString healthFile = transaction.healthFile();
|
||||
QFile::remove(healthFile);
|
||||
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation..."));
|
||||
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for a health confirmation..."));
|
||||
if (!launchMainApp(healthFile))
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"),
|
||||
mainStartupError.isEmpty()
|
||||
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
|
||||
: mainStartupError);
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Startup Failed"),
|
||||
QCoreApplication::translate("Updater", "%1 could not be started.").arg(mainExecutable));
|
||||
|
||||
QElapsedTimer healthTimer;
|
||||
healthTimer.start();
|
||||
@@ -513,23 +502,24 @@ int main(int argc, char* argv[])
|
||||
QThread::msleep(100);
|
||||
}
|
||||
if (!QFile::exists(healthFile))
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version did not complete startup health confirmation within %1 milliseconds.")
|
||||
.arg(healthCheckTimeoutMs));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Startup Confirmation Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version did not complete its startup health confirmation within %1 milliseconds.").arg(healthCheckTimeoutMs));
|
||||
|
||||
setProgress(99, QCoreApplication::translate("Updater", "Committing update transaction..."));
|
||||
setProgress(99, QCoreApplication::translate("Updater", "Committing the update transaction..."));
|
||||
if (!transaction.commit())
|
||||
return delegateRollback(QCoreApplication::translate("Updater", "Transaction Commit Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version has started, but the update transaction could not be committed."));
|
||||
return delegateRollback(
|
||||
QCoreApplication::translate("Updater", "Transaction Commit Failed"),
|
||||
QCoreApplication::translate("Updater", "The new version started, but the update transaction could not be committed."));
|
||||
|
||||
QFile::remove(healthFile);
|
||||
QFile::remove(bootstrapPlanFile());
|
||||
reportUpdateResult(true);
|
||||
progress.setValue(100);
|
||||
progress.close();
|
||||
QMessageBox::information(nullptr,
|
||||
QMessageBox::information(
|
||||
nullptr,
|
||||
QCoreApplication::translate("Updater", "Update Complete"),
|
||||
QCoreApplication::translate("Updater", "The software has been successfully updated to %1 and passed the startup health check.")
|
||||
.arg(targetVersion));
|
||||
QCoreApplication::translate("Updater", "The software was updated successfully to %1 and passed the startup health check.").arg(targetVersion));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
cmake_minimum_required(VERSION 3.20)
|
||||
|
||||
if(NOT UPDATE_CLIENT_SCAN_ROOT)
|
||||
message(FATAL_ERROR "UPDATE_CLIENT_SCAN_ROOT is required.")
|
||||
endif()
|
||||
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_SCAN_ROOT
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _scan_root
|
||||
)
|
||||
if(NOT IS_DIRECTORY "${_scan_root}")
|
||||
message(FATAL_ERROR "Production source scan root does not exist: ${_scan_root}")
|
||||
endif()
|
||||
|
||||
file(GLOB_RECURSE _all_files LIST_DIRECTORIES FALSE "${_scan_root}/*")
|
||||
set(_production_files)
|
||||
foreach(_candidate IN LISTS _all_files)
|
||||
cmake_path(CONVERT "${_candidate}" TO_CMAKE_PATH_LIST _normalized_candidate NORMALIZE)
|
||||
cmake_path(
|
||||
RELATIVE_PATH _normalized_candidate
|
||||
BASE_DIRECTORY "${_scan_root}"
|
||||
OUTPUT_VARIABLE _relative_candidate
|
||||
)
|
||||
string(TOLOWER "${_relative_candidate}" _candidate_lower)
|
||||
if(_candidate_lower MATCHES
|
||||
"(^|/)(translations|docs|out|build|generated|cmake-build[^/]*|\\.git)(/|$)")
|
||||
continue()
|
||||
endif()
|
||||
if(_candidate_lower STREQUAL "bootstrap/bootstrap.rc")
|
||||
continue()
|
||||
endif()
|
||||
|
||||
cmake_path(GET _normalized_candidate FILENAME _file_name)
|
||||
cmake_path(GET _normalized_candidate EXTENSION _file_extension)
|
||||
string(TOLOWER "${_file_extension}" _file_extension)
|
||||
if(NOT _file_name STREQUAL "CMakeLists.txt"
|
||||
AND NOT _file_extension MATCHES
|
||||
"^\\.(c|cc|cpp|cxx|h|hh|hpp|cmake|ui|qrc|manifest|rc|json)$")
|
||||
continue()
|
||||
endif()
|
||||
list(APPEND _production_files "${_candidate}")
|
||||
endforeach()
|
||||
|
||||
set(_han_utf8_regex
|
||||
"(e3:[9ab][0-9a-f]:[89ab][0-9a-f]:"
|
||||
"|e[4-9]:[89ab][0-9a-f]:[89ab][0-9a-f]:"
|
||||
"|ef:a[4-9ab]:[89ab][0-9a-f]:"
|
||||
"|f0:a[0-9a-f]:[89ab][0-9a-f]:[89ab][0-9a-f]:"
|
||||
"|f0:b[01]:[89ab][0-9a-f]:[89ab][0-9a-f]:)"
|
||||
)
|
||||
string(JOIN "" _han_utf8_regex ${_han_utf8_regex})
|
||||
|
||||
set(_violations)
|
||||
foreach(_production_file IN LISTS _production_files)
|
||||
file(READ "${_production_file}" _file_hex HEX)
|
||||
string(TOLOWER "${_file_hex}" _file_hex)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "\\1:" _file_bytes "${_file_hex}")
|
||||
if(_file_bytes MATCHES "${_han_utf8_regex}")
|
||||
cmake_path(
|
||||
RELATIVE_PATH _production_file
|
||||
BASE_DIRECTORY "${_scan_root}"
|
||||
OUTPUT_VARIABLE _relative_file
|
||||
)
|
||||
list(APPEND _violations "${_relative_file}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_violations)
|
||||
list(JOIN _violations "\n " _violation_list)
|
||||
message(FATAL_ERROR
|
||||
"Han characters are forbidden in update-client production sources:\n"
|
||||
" ${_violation_list}\n"
|
||||
"Move user-visible text to the Qt translation catalog or the approved "
|
||||
"Bootstrap STRINGTABLE resource."
|
||||
)
|
||||
endif()
|
||||
|
||||
list(LENGTH _production_files _production_file_count)
|
||||
message(STATUS
|
||||
"No-Han production source check passed (${_production_file_count} files)."
|
||||
)
|
||||
@@ -0,0 +1,329 @@
|
||||
include_guard(DIRECTORY)
|
||||
|
||||
function(_update_client_normalize_thirdparty_root)
|
||||
if(NOT UPDATE_CLIENT_THIRDPARTY_ROOT)
|
||||
message(FATAL_ERROR
|
||||
"UPDATE_CLIENT_THIRDPARTY_ROOT is empty. Set it to the project third-party "
|
||||
"root, normally <SimCAE source>/3rdparty."
|
||||
)
|
||||
endif()
|
||||
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_THIRDPARTY_ROOT
|
||||
BASE_DIRECTORY "${UPDATE_CLIENT_SOURCE_DIR}"
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _absolute_root
|
||||
)
|
||||
if(NOT IS_DIRECTORY "${_absolute_root}")
|
||||
message(FATAL_ERROR
|
||||
"UPDATE_CLIENT_THIRDPARTY_ROOT does not exist: ${_absolute_root}\n"
|
||||
"Provision the project dependencies or pass the correct project-relative root."
|
||||
)
|
||||
endif()
|
||||
|
||||
set(
|
||||
UPDATE_CLIENT_THIRDPARTY_ROOT
|
||||
"${_absolute_root}"
|
||||
CACHE PATH
|
||||
"Root directory for update-client third-party packages"
|
||||
FORCE
|
||||
)
|
||||
set(UPDATE_CLIENT_THIRDPARTY_ROOT "${_absolute_root}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_assert_path_under_root candidate_path description root_path)
|
||||
if(candidate_path MATCHES "^\\$<")
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation cannot verify ${description}: ${candidate_path}"
|
||||
)
|
||||
endif()
|
||||
if(NOT EXISTS "${candidate_path}")
|
||||
message(FATAL_ERROR "${description} does not exist: ${candidate_path}")
|
||||
endif()
|
||||
|
||||
file(REAL_PATH "${root_path}" _real_root)
|
||||
file(REAL_PATH "${candidate_path}" _real_candidate)
|
||||
set(_root_for_prefix_check "${_real_root}")
|
||||
cmake_path(
|
||||
IS_PREFIX _root_for_prefix_check "${_real_candidate}"
|
||||
NORMALIZE
|
||||
_is_under_root
|
||||
)
|
||||
if(NOT _is_under_root)
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation rejected ${description}:\n"
|
||||
" resolved path: ${_real_candidate}\n"
|
||||
" required root: ${_real_root}\n"
|
||||
"Provision OpenSSL under UPDATE_CLIENT_THIRDPARTY_ROOT or disable "
|
||||
"UPDATE_CLIENT_STRICT_THIRDPARTY only for local dependency diagnosis."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_assert_openssl_target_under_root target_name root_path)
|
||||
set(_location_properties IMPORTED_LOCATION IMPORTED_IMPLIB)
|
||||
get_target_property(_imported_configurations "${target_name}" IMPORTED_CONFIGURATIONS)
|
||||
if(_imported_configurations AND NOT _imported_configurations MATCHES "-NOTFOUND$")
|
||||
foreach(_configuration IN LISTS _imported_configurations)
|
||||
string(TOUPPER "${_configuration}" _configuration_upper)
|
||||
list(APPEND _location_properties
|
||||
"IMPORTED_LOCATION_${_configuration_upper}"
|
||||
"IMPORTED_IMPLIB_${_configuration_upper}"
|
||||
)
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
set(_has_library_location OFF)
|
||||
foreach(_property IN LISTS _location_properties)
|
||||
get_target_property(_locations "${target_name}" "${_property}")
|
||||
if(NOT _locations OR _locations MATCHES "-NOTFOUND$")
|
||||
continue()
|
||||
endif()
|
||||
foreach(_location IN LISTS _locations)
|
||||
_update_client_assert_path_under_root(
|
||||
"${_location}"
|
||||
"${target_name} ${_property}"
|
||||
"${root_path}"
|
||||
)
|
||||
set(_has_library_location ON)
|
||||
endforeach()
|
||||
endforeach()
|
||||
if(NOT _has_library_location)
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation found no imported library location for ${target_name}."
|
||||
)
|
||||
endif()
|
||||
|
||||
get_target_property(_include_directories "${target_name}" INTERFACE_INCLUDE_DIRECTORIES)
|
||||
if(NOT _include_directories OR _include_directories MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR
|
||||
"Strict third-party validation found no imported include directory for ${target_name}."
|
||||
)
|
||||
endif()
|
||||
foreach(_include_directory IN LISTS _include_directories)
|
||||
if(_include_directory MATCHES "^\\$<BUILD_INTERFACE:(.*)>$")
|
||||
set(_include_directory "${CMAKE_MATCH_1}")
|
||||
elseif(_include_directory MATCHES "^\\$<INSTALL_INTERFACE:")
|
||||
continue()
|
||||
endif()
|
||||
_update_client_assert_path_under_root(
|
||||
"${_include_directory}"
|
||||
"${target_name} INTERFACE_INCLUDE_DIRECTORIES"
|
||||
"${root_path}"
|
||||
)
|
||||
endforeach()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_attach_openssl_runtime target_name runtime_dll root_path)
|
||||
_update_client_assert_path_under_root(
|
||||
"${runtime_dll}"
|
||||
"${target_name} runtime DLL"
|
||||
"${root_path}"
|
||||
)
|
||||
set_property(
|
||||
TARGET "${target_name}"
|
||||
PROPERTY UPDATE_CLIENT_RUNTIME_DLL "${runtime_dll}"
|
||||
)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_discover_openssl_runtime)
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_runtime_directory "${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL/bin")
|
||||
if(NOT IS_DIRECTORY "${_runtime_directory}")
|
||||
set(_runtime_directory "${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl/bin")
|
||||
endif()
|
||||
if(NOT IS_DIRECTORY "${_runtime_directory}")
|
||||
message(FATAL_ERROR
|
||||
"Windows OpenSSL runtime directory is missing under "
|
||||
"UPDATE_CLIENT_THIRDPARTY_ROOT: expected OpenSSL/bin. "
|
||||
"Release output requires vendored libssl and libcrypto DLLs."
|
||||
)
|
||||
endif()
|
||||
|
||||
file(GLOB _runtime_candidates CONFIGURE_DEPENDS "${_runtime_directory}/*.dll")
|
||||
set(_ssl_runtime_candidates)
|
||||
set(_crypto_runtime_candidates)
|
||||
foreach(_runtime_candidate IN LISTS _runtime_candidates)
|
||||
cmake_path(GET _runtime_candidate FILENAME _runtime_name)
|
||||
string(TOLOWER "${_runtime_name}" _runtime_name_lower)
|
||||
if(_runtime_name_lower MATCHES "^libssl.*\\.dll$")
|
||||
list(APPEND _ssl_runtime_candidates "${_runtime_candidate}")
|
||||
elseif(_runtime_name_lower MATCHES "^libcrypto.*\\.dll$")
|
||||
list(APPEND _crypto_runtime_candidates "${_runtime_candidate}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
list(LENGTH _ssl_runtime_candidates _ssl_runtime_count)
|
||||
list(LENGTH _crypto_runtime_candidates _crypto_runtime_count)
|
||||
if(NOT _ssl_runtime_count EQUAL 1 OR NOT _crypto_runtime_count EQUAL 1)
|
||||
message(FATAL_ERROR
|
||||
"Windows Release runtime deployment requires exactly one libssl*.dll and "
|
||||
"one libcrypto*.dll in ${_runtime_directory}. Found SSL=${_ssl_runtime_count}, "
|
||||
"Crypto=${_crypto_runtime_count}. Keep only the runtime matching the imported "
|
||||
"OpenSSL package and target architecture."
|
||||
)
|
||||
endif()
|
||||
|
||||
list(GET _ssl_runtime_candidates 0 _ssl_runtime)
|
||||
list(GET _crypto_runtime_candidates 0 _crypto_runtime)
|
||||
_update_client_attach_openssl_runtime(
|
||||
OpenSSL::SSL "${_ssl_runtime}" "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
_update_client_attach_openssl_runtime(
|
||||
OpenSSL::Crypto "${_crypto_runtime}" "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
message(STATUS "update-client OpenSSL SSL runtime: ${_ssl_runtime}")
|
||||
message(STATUS "update-client OpenSSL Crypto runtime: ${_crypto_runtime}")
|
||||
endfunction()
|
||||
|
||||
function(update_client_find_dependencies)
|
||||
_update_client_normalize_thirdparty_root()
|
||||
|
||||
set(_package_prefixes
|
||||
"${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
"${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL"
|
||||
"${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl"
|
||||
)
|
||||
foreach(_prefix IN LISTS _package_prefixes)
|
||||
if(IS_DIRECTORY "${_prefix}")
|
||||
list(PREPEND CMAKE_PREFIX_PATH "${_prefix}")
|
||||
endif()
|
||||
endforeach()
|
||||
list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH)
|
||||
set(CMAKE_PREFIX_PATH "${CMAKE_PREFIX_PATH}" PARENT_SCOPE)
|
||||
|
||||
find_package(Qt5 5.15 CONFIG QUIET COMPONENTS Core Gui Network Widgets)
|
||||
if(NOT Qt5_FOUND)
|
||||
message(FATAL_ERROR
|
||||
"Qt 5.15 or newer was not found through its CONFIG package.\n"
|
||||
"The parent project should discover Qt first and pass Qt5_DIR. For a "
|
||||
"standalone build, set Qt5_DIR to the directory containing Qt5Config.cmake "
|
||||
"or add the Qt installation prefix to CMAKE_PREFIX_PATH."
|
||||
)
|
||||
endif()
|
||||
|
||||
if(NOT OPENSSL_ROOT_DIR)
|
||||
if(IS_DIRECTORY "${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL")
|
||||
set(OPENSSL_ROOT_DIR "${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL")
|
||||
elseif(IS_DIRECTORY "${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl")
|
||||
set(OPENSSL_ROOT_DIR "${UPDATE_CLIENT_THIRDPARTY_ROOT}/openssl")
|
||||
endif()
|
||||
endif()
|
||||
find_package(OpenSSL QUIET COMPONENTS SSL Crypto)
|
||||
if(NOT OpenSSL_FOUND OR NOT TARGET OpenSSL::SSL OR NOT TARGET OpenSSL::Crypto)
|
||||
message(FATAL_ERROR
|
||||
"OpenSSL with SSL and Crypto imported targets was not found.\n"
|
||||
"Provision OpenSSL under ${UPDATE_CLIENT_THIRDPARTY_ROOT}/OpenSSL or set "
|
||||
"OPENSSL_ROOT_DIR to an OpenSSL package inside UPDATE_CLIENT_THIRDPARTY_ROOT. "
|
||||
"Do not pass raw include or library directories."
|
||||
)
|
||||
endif()
|
||||
|
||||
if(UPDATE_CLIENT_STRICT_THIRDPARTY)
|
||||
_update_client_assert_openssl_target_under_root(
|
||||
OpenSSL::SSL "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
_update_client_assert_openssl_target_under_root(
|
||||
OpenSSL::Crypto "${UPDATE_CLIENT_THIRDPARTY_ROOT}"
|
||||
)
|
||||
endif()
|
||||
_update_client_discover_openssl_runtime()
|
||||
|
||||
if(WIN32 AND UPDATE_CLIENT_DEPLOY_QT_RUNTIME)
|
||||
if(NOT TARGET Qt5::qmake)
|
||||
message(FATAL_ERROR
|
||||
"Qt5::qmake is unavailable, so windeployqt cannot be located. "
|
||||
"Use a complete Qt 5 CONFIG package or disable "
|
||||
"UPDATE_CLIENT_DEPLOY_QT_RUNTIME when deployment is handled by the caller."
|
||||
)
|
||||
endif()
|
||||
get_target_property(_qmake_executable Qt5::qmake IMPORTED_LOCATION)
|
||||
cmake_path(GET _qmake_executable PARENT_PATH _qt_binary_dir)
|
||||
find_program(
|
||||
UPDATE_CLIENT_WINDEPLOYQT_EXECUTABLE
|
||||
NAMES windeployqt
|
||||
HINTS "${_qt_binary_dir}"
|
||||
NO_DEFAULT_PATH
|
||||
DOC "Qt deployment tool used by update-client"
|
||||
)
|
||||
if(NOT UPDATE_CLIENT_WINDEPLOYQT_EXECUTABLE)
|
||||
message(FATAL_ERROR
|
||||
"windeployqt was not found next to ${_qmake_executable}. "
|
||||
"Install the Qt deployment tools or configure with "
|
||||
"-DUPDATE_CLIENT_DEPLOY_QT_RUNTIME=OFF when the caller deploys Qt."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(update_client_deploy_openssl_runtime target_name)
|
||||
if(NOT TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Unknown update-client target: ${target_name}")
|
||||
endif()
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
foreach(_openssl_target IN ITEMS OpenSSL::SSL OpenSSL::Crypto)
|
||||
get_target_property(
|
||||
_runtime_dll "${_openssl_target}" UPDATE_CLIENT_RUNTIME_DLL
|
||||
)
|
||||
if(NOT _runtime_dll OR _runtime_dll MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR
|
||||
"${_openssl_target} has no validated UPDATE_CLIENT_RUNTIME_DLL property."
|
||||
)
|
||||
endif()
|
||||
cmake_path(GET _runtime_dll FILENAME _runtime_name)
|
||||
add_custom_command(
|
||||
TARGET "${target_name}"
|
||||
POST_BUILD
|
||||
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
|
||||
"${_runtime_dll}"
|
||||
"$<TARGET_FILE_DIR:${target_name}>/${_runtime_name}"
|
||||
COMMENT "Deploying ${_runtime_name} for ${target_name}"
|
||||
VERBATIM
|
||||
)
|
||||
endforeach()
|
||||
endfunction()
|
||||
|
||||
function(update_client_install_openssl_runtime destination)
|
||||
if(NOT WIN32)
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(_runtime_dlls)
|
||||
foreach(_openssl_target IN ITEMS OpenSSL::SSL OpenSSL::Crypto)
|
||||
get_target_property(
|
||||
_runtime_dll "${_openssl_target}" UPDATE_CLIENT_RUNTIME_DLL
|
||||
)
|
||||
if(NOT _runtime_dll OR _runtime_dll MATCHES "-NOTFOUND$")
|
||||
message(FATAL_ERROR
|
||||
"${_openssl_target} has no validated runtime DLL for installation."
|
||||
)
|
||||
endif()
|
||||
list(APPEND _runtime_dlls "${_runtime_dll}")
|
||||
endforeach()
|
||||
install(FILES ${_runtime_dlls} DESTINATION "${destination}")
|
||||
endfunction()
|
||||
|
||||
function(update_client_enable_qt_deployment target_name)
|
||||
if(NOT TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Unknown update-client target: ${target_name}")
|
||||
endif()
|
||||
if(NOT WIN32 OR NOT UPDATE_CLIENT_DEPLOY_QT_RUNTIME)
|
||||
return()
|
||||
endif()
|
||||
|
||||
add_custom_command(
|
||||
TARGET "${target_name}"
|
||||
POST_BUILD
|
||||
COMMAND "${UPDATE_CLIENT_WINDEPLOYQT_EXECUTABLE}"
|
||||
"$<IF:$<CONFIG:Debug>,--debug,--release>"
|
||||
"$<TARGET_FILE:${target_name}>"
|
||||
COMMENT "Deploying Qt runtime for ${target_name}"
|
||||
VERBATIM
|
||||
)
|
||||
endfunction()
|
||||
@@ -0,0 +1,889 @@
|
||||
include_guard(DIRECTORY)
|
||||
|
||||
function(_update_client_decode_utf8 codepoints_variable widths_variable input_value)
|
||||
string(HEX "${input_value}" _hex)
|
||||
string(LENGTH "${_hex}" _hex_length)
|
||||
set(_codepoints)
|
||||
set(_widths)
|
||||
set(_offset 0)
|
||||
while(_offset LESS _hex_length)
|
||||
string(SUBSTRING "${_hex}" ${_offset} 2 _first_hex)
|
||||
math(EXPR _first "0x${_first_hex}")
|
||||
if(_first LESS 128)
|
||||
set(_width 1)
|
||||
set(_codepoint ${_first})
|
||||
elseif(_first LESS 224)
|
||||
math(EXPR _second_offset "${_offset} + 2")
|
||||
string(SUBSTRING "${_hex}" ${_second_offset} 2 _second_hex)
|
||||
math(EXPR _second "0x${_second_hex}")
|
||||
math(EXPR _codepoint "((${_first} & 31) << 6) | (${_second} & 63)")
|
||||
set(_width 2)
|
||||
elseif(_first LESS 240)
|
||||
math(EXPR _second_offset "${_offset} + 2")
|
||||
math(EXPR _third_offset "${_offset} + 4")
|
||||
string(SUBSTRING "${_hex}" ${_second_offset} 2 _second_hex)
|
||||
string(SUBSTRING "${_hex}" ${_third_offset} 2 _third_hex)
|
||||
math(EXPR _second "0x${_second_hex}")
|
||||
math(EXPR _third "0x${_third_hex}")
|
||||
math(EXPR _codepoint
|
||||
"((${_first} & 15) << 12) | ((${_second} & 63) << 6) | (${_third} & 63)"
|
||||
)
|
||||
set(_width 3)
|
||||
else()
|
||||
math(EXPR _second_offset "${_offset} + 2")
|
||||
math(EXPR _third_offset "${_offset} + 4")
|
||||
math(EXPR _fourth_offset "${_offset} + 6")
|
||||
string(SUBSTRING "${_hex}" ${_second_offset} 2 _second_hex)
|
||||
string(SUBSTRING "${_hex}" ${_third_offset} 2 _third_hex)
|
||||
string(SUBSTRING "${_hex}" ${_fourth_offset} 2 _fourth_hex)
|
||||
math(EXPR _second "0x${_second_hex}")
|
||||
math(EXPR _third "0x${_third_hex}")
|
||||
math(EXPR _fourth "0x${_fourth_hex}")
|
||||
string(CONCAT _expression
|
||||
"((${_first} & 7) << 18) | ((${_second} & 63) << 12) | "
|
||||
"((${_third} & 63) << 6) | (${_fourth} & 63)"
|
||||
)
|
||||
math(EXPR _codepoint "${_expression}")
|
||||
set(_width 4)
|
||||
endif()
|
||||
list(APPEND _codepoints "${_codepoint}")
|
||||
list(APPEND _widths "${_width}")
|
||||
math(EXPR _offset "${_offset} + (${_width} * 2)")
|
||||
endwhile()
|
||||
set(${codepoints_variable} "${_codepoints}" PARENT_SCOPE)
|
||||
set(${widths_variable} "${_widths}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_codepoint_is_qt_space output_variable codepoint)
|
||||
if((codepoint GREATER_EQUAL 9 AND codepoint LESS_EQUAL 13)
|
||||
OR codepoint EQUAL 32
|
||||
OR codepoint EQUAL 160
|
||||
OR codepoint EQUAL 5760
|
||||
OR (codepoint GREATER_EQUAL 8192 AND codepoint LESS_EQUAL 8202)
|
||||
OR codepoint EQUAL 8232
|
||||
OR codepoint EQUAL 8233
|
||||
OR codepoint EQUAL 8239
|
||||
OR codepoint EQUAL 8287
|
||||
OR codepoint EQUAL 12288)
|
||||
set(${output_variable} TRUE PARENT_SCOPE)
|
||||
else()
|
||||
set(${output_variable} FALSE PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_qt_trim output_variable input_value)
|
||||
_update_client_decode_utf8(_codepoints _widths "${input_value}")
|
||||
list(LENGTH _codepoints _codepoint_count)
|
||||
set(_first_non_space_byte -1)
|
||||
set(_last_non_space_end_byte -1)
|
||||
set(_byte_offset 0)
|
||||
if(_codepoint_count GREATER 0)
|
||||
math(EXPR _last_index "${_codepoint_count} - 1")
|
||||
foreach(_index RANGE 0 ${_last_index})
|
||||
list(GET _codepoints ${_index} _codepoint)
|
||||
list(GET _widths ${_index} _width)
|
||||
_update_client_codepoint_is_qt_space(_is_space "${_codepoint}")
|
||||
if(NOT _is_space)
|
||||
if(_first_non_space_byte EQUAL -1)
|
||||
set(_first_non_space_byte ${_byte_offset})
|
||||
endif()
|
||||
math(EXPR _last_non_space_end_byte "${_byte_offset} + ${_width}")
|
||||
endif()
|
||||
math(EXPR _byte_offset "${_byte_offset} + ${_width}")
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(_first_non_space_byte EQUAL -1)
|
||||
set(_trimmed "")
|
||||
else()
|
||||
math(EXPR _trimmed_length
|
||||
"${_last_non_space_end_byte} - ${_first_non_space_byte}"
|
||||
)
|
||||
string(SUBSTRING "${input_value}" ${_first_non_space_byte} ${_trimmed_length} _trimmed)
|
||||
endif()
|
||||
set(${output_variable} "${_trimmed}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_string_properties
|
||||
control_variable space_variable utf16_length_variable input_value)
|
||||
_update_client_decode_utf8(_codepoints _widths "${input_value}")
|
||||
set(_has_control FALSE)
|
||||
set(_has_space FALSE)
|
||||
set(_utf16_length 0)
|
||||
foreach(_codepoint IN LISTS _codepoints)
|
||||
if(_codepoint LESS_EQUAL 31
|
||||
OR (_codepoint GREATER_EQUAL 127 AND _codepoint LESS_EQUAL 159))
|
||||
set(_has_control TRUE)
|
||||
endif()
|
||||
_update_client_codepoint_is_qt_space(_is_space "${_codepoint}")
|
||||
if(_is_space)
|
||||
set(_has_space TRUE)
|
||||
endif()
|
||||
if(_codepoint GREATER 65535)
|
||||
math(EXPR _utf16_length "${_utf16_length} + 2")
|
||||
else()
|
||||
math(EXPR _utf16_length "${_utf16_length} + 1")
|
||||
endif()
|
||||
endforeach()
|
||||
set(${control_variable} ${_has_control} PARENT_SCOPE)
|
||||
set(${space_variable} ${_has_space} PARENT_SCOPE)
|
||||
set(${utf16_length_variable} ${_utf16_length} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_require_json_string json_text field_name)
|
||||
string(JSON _field_type ERROR_VARIABLE _field_error TYPE "${json_text}" "${field_name}")
|
||||
if(NOT _field_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR
|
||||
"Product config is missing required field '${field_name}': ${_field_error}"
|
||||
)
|
||||
endif()
|
||||
if(NOT _field_type STREQUAL "STRING")
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' must be a string, got ${_field_type}."
|
||||
)
|
||||
endif()
|
||||
|
||||
string(JSON _field_value GET "${json_text}" "${field_name}")
|
||||
_update_client_qt_trim(_trimmed_field_value "${_field_value}")
|
||||
if(_trimmed_field_value STREQUAL "")
|
||||
message(FATAL_ERROR "Product config field '${field_name}' must not be empty.")
|
||||
endif()
|
||||
|
||||
set("UPDATE_CLIENT_CONFIG_${field_name}" "${_field_value}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_require_positive_integer json_text field_name)
|
||||
string(JSON _field_type ERROR_VARIABLE _field_error TYPE "${json_text}" "${field_name}")
|
||||
if(NOT _field_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR
|
||||
"Product config is missing required field '${field_name}': ${_field_error}"
|
||||
)
|
||||
endif()
|
||||
if(NOT _field_type STREQUAL "STRING" AND NOT _field_type STREQUAL "NUMBER")
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' must be a string or number, got ${_field_type}."
|
||||
)
|
||||
endif()
|
||||
string(JSON _field_value GET "${json_text}" "${field_name}")
|
||||
if(NOT _field_value MATCHES "^[1-9][0-9]*$")
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' must contain a positive integer."
|
||||
)
|
||||
endif()
|
||||
string(LENGTH "${_field_value}" _field_length)
|
||||
if(_field_length GREATER 10
|
||||
OR (_field_length EQUAL 10 AND _field_value STRGREATER "2147483647"))
|
||||
message(FATAL_ERROR
|
||||
"Product config field '${field_name}' exceeds the supported integer range."
|
||||
)
|
||||
endif()
|
||||
set("UPDATE_CLIENT_CONFIG_${field_name}" "${_field_value}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_prepare_qdir_path
|
||||
relative_variable clean_variable absolute_variable input_value)
|
||||
_update_client_qt_trim(_relative "${input_value}")
|
||||
string(REPLACE "\\" "/" _relative "${_relative}")
|
||||
set(_path_for_cmake "${_relative}")
|
||||
cmake_path(IS_ABSOLUTE _path_for_cmake _is_absolute)
|
||||
set(_clean "${_relative}")
|
||||
cmake_path(NORMAL_PATH _clean)
|
||||
if(NOT _clean STREQUAL "/")
|
||||
string(REGEX REPLACE "/+$" "" _clean "${_clean}")
|
||||
endif()
|
||||
if(_clean STREQUAL "")
|
||||
set(_clean ".")
|
||||
endif()
|
||||
set(${relative_variable} "${_relative}" PARENT_SCOPE)
|
||||
set(${clean_variable} "${_clean}" PARENT_SCOPE)
|
||||
set(${absolute_variable} ${_is_absolute} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_install_root output_variable input_value validation_context)
|
||||
_update_client_prepare_qdir_path(_relative _clean _is_absolute "${input_value}")
|
||||
if(_is_absolute OR _relative MATCHES ":" OR NOT _clean STREQUAL _relative)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'install_root' must be a normalized relative path."
|
||||
)
|
||||
endif()
|
||||
set(${output_variable} "${_relative}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_strict_relative_path field_name input_value validation_context)
|
||||
_update_client_prepare_qdir_path(_relative _clean _is_absolute "${input_value}")
|
||||
if(_relative STREQUAL ""
|
||||
OR _is_absolute
|
||||
OR _relative MATCHES ":"
|
||||
OR NOT _clean STREQUAL _relative
|
||||
OR _relative MATCHES "(^|/)\\.\\.?(/|$)")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field '${field_name}' must be a safe relative path."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_split_leading_parents
|
||||
count_variable remainder_variable input_value)
|
||||
set(_remaining "${input_value}")
|
||||
set(_count 0)
|
||||
if(_remaining STREQUAL ".")
|
||||
set(_remaining "")
|
||||
else()
|
||||
while(_remaining MATCHES "^\\.\\./")
|
||||
math(EXPR _count "${_count} + 1")
|
||||
string(SUBSTRING "${_remaining}" 3 -1 _remaining)
|
||||
endwhile()
|
||||
if(_remaining STREQUAL "..")
|
||||
math(EXPR _count "${_count} + 1")
|
||||
set(_remaining "")
|
||||
endif()
|
||||
endif()
|
||||
set(${count_variable} ${_count} PARENT_SCOPE)
|
||||
set(${remainder_variable} "${_remaining}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_main_executable input_value install_root validation_context)
|
||||
_update_client_prepare_qdir_path(_relative _clean _is_absolute "${input_value}")
|
||||
if(_relative STREQUAL ""
|
||||
OR _is_absolute
|
||||
OR _relative MATCHES ":"
|
||||
OR NOT _clean STREQUAL _relative
|
||||
OR _relative STREQUAL "."
|
||||
OR _relative STREQUAL "..")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'main_executable' must stay within install_root."
|
||||
)
|
||||
endif()
|
||||
|
||||
_update_client_split_leading_parents(
|
||||
_install_parent_count _install_remainder "${install_root}"
|
||||
)
|
||||
_update_client_split_leading_parents(
|
||||
_main_parent_count _main_remainder "${_relative}"
|
||||
)
|
||||
set(_is_within_root FALSE)
|
||||
if(_install_remainder STREQUAL "")
|
||||
if(_main_parent_count LESS_EQUAL _install_parent_count)
|
||||
set(_is_within_root TRUE)
|
||||
endif()
|
||||
elseif(_main_parent_count EQUAL _install_parent_count)
|
||||
if(_main_remainder STREQUAL _install_remainder)
|
||||
set(_is_within_root TRUE)
|
||||
else()
|
||||
string(FIND "${_main_remainder}" "${_install_remainder}/" _root_prefix_index)
|
||||
if(_root_prefix_index EQUAL 0)
|
||||
set(_is_within_root TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(NOT _is_within_root)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'main_executable' must stay within install_root."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_count_ipv6_groups count_variable valid_variable input_value)
|
||||
set(_remaining "${input_value}")
|
||||
set(_count 0)
|
||||
set(_valid TRUE)
|
||||
while(NOT _remaining STREQUAL "")
|
||||
string(FIND "${_remaining}" ":" _separator_index)
|
||||
if(_separator_index EQUAL -1)
|
||||
set(_group "${_remaining}")
|
||||
set(_remaining "")
|
||||
else()
|
||||
string(SUBSTRING "${_remaining}" 0 ${_separator_index} _group)
|
||||
math(EXPR _next_index "${_separator_index} + 1")
|
||||
string(SUBSTRING "${_remaining}" ${_next_index} -1 _remaining)
|
||||
endif()
|
||||
string(LENGTH "${_group}" _group_length)
|
||||
if(_group STREQUAL ""
|
||||
OR _group_length GREATER 4
|
||||
OR NOT _group MATCHES "^[0-9A-Fa-f]+$")
|
||||
set(_valid FALSE)
|
||||
break()
|
||||
endif()
|
||||
math(EXPR _count "${_count} + 1")
|
||||
endwhile()
|
||||
set(${count_variable} ${_count} PARENT_SCOPE)
|
||||
set(${valid_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_is_valid_ipv6 output_variable input_value)
|
||||
set(_valid FALSE)
|
||||
if(input_value MATCHES "^[0-9A-Fa-f:]+$")
|
||||
string(FIND "${input_value}" "::" _compression_index)
|
||||
if(_compression_index EQUAL -1)
|
||||
_update_client_count_ipv6_groups(_group_count _groups_valid "${input_value}")
|
||||
if(_groups_valid AND _group_count EQUAL 8)
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
else()
|
||||
string(SUBSTRING "${input_value}" 0 ${_compression_index} _left)
|
||||
math(EXPR _right_index "${_compression_index} + 2")
|
||||
string(SUBSTRING "${input_value}" ${_right_index} -1 _right)
|
||||
string(FIND "${_right}" "::" _second_compression_index)
|
||||
if(NOT _left MATCHES ":$"
|
||||
AND NOT _right MATCHES "^:"
|
||||
AND _second_compression_index EQUAL -1)
|
||||
_update_client_count_ipv6_groups(_left_count _left_valid "${_left}")
|
||||
_update_client_count_ipv6_groups(_right_count _right_valid "${_right}")
|
||||
math(EXPR _explicit_group_count "${_left_count} + ${_right_count}")
|
||||
if(_left_valid AND _right_valid AND _explicit_group_count LESS 8)
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
set(${output_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_is_valid_url_port output_variable input_value)
|
||||
set(_valid FALSE)
|
||||
if(input_value MATCHES "^[0-9]+$")
|
||||
string(REGEX REPLACE "^0+" "" _significant "${input_value}")
|
||||
if(_significant STREQUAL "")
|
||||
set(_significant 0)
|
||||
endif()
|
||||
string(LENGTH "${_significant}" _length)
|
||||
if(_length LESS 6
|
||||
AND NOT (_length EQUAL 5 AND _significant STRGREATER "65535"))
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
set(${output_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_http_url input_value validation_context)
|
||||
set(_valid TRUE)
|
||||
if(NOT input_value MATCHES "^[Hh][Tt][Tt][Pp]([Ss])?://")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
string(REGEX REPLACE
|
||||
"^[Hh][Tt][Tt][Pp]([Ss])?://" "" _url_remainder "${input_value}"
|
||||
)
|
||||
string(REGEX MATCH "^[^/?#]*" _authority "${_url_remainder}")
|
||||
if(_authority STREQUAL "")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
_update_client_string_properties(
|
||||
_url_has_control _unused_space _unused_length "${input_value}"
|
||||
)
|
||||
_update_client_string_properties(
|
||||
_unused_control _authority_has_space _unused_length "${_authority}"
|
||||
)
|
||||
if(_url_has_control OR _authority_has_space)
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(_valid)
|
||||
string(REGEX REPLACE "^.*@" "" _host_port "${_authority}")
|
||||
if(_host_port MATCHES "^\\[")
|
||||
if(NOT _host_port MATCHES "^\\[([^]]+)\\](.*)$")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
set(_host "${CMAKE_MATCH_1}")
|
||||
set(_port_suffix "${CMAKE_MATCH_2}")
|
||||
_update_client_is_valid_ipv6(_host_valid "${_host}")
|
||||
if(NOT _host_valid)
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
endif()
|
||||
else()
|
||||
if(_host_port MATCHES "[\\[\\]]")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
string(REGEX REPLACE "[^:]" "" _colons "${_host_port}")
|
||||
string(LENGTH "${_colons}" _colon_count)
|
||||
if(_colon_count GREATER 1)
|
||||
set(_valid FALSE)
|
||||
elseif(_colon_count EQUAL 1)
|
||||
string(FIND "${_host_port}" ":" _colon_index)
|
||||
string(SUBSTRING "${_host_port}" 0 ${_colon_index} _host)
|
||||
math(EXPR _port_index "${_colon_index} + 1")
|
||||
string(SUBSTRING "${_host_port}" ${_port_index} -1 _port)
|
||||
set(_port_suffix ":${_port}")
|
||||
else()
|
||||
set(_host "${_host_port}")
|
||||
set(_port_suffix "")
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(_valid AND _host STREQUAL "")
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
if(_valid AND NOT _port_suffix STREQUAL "")
|
||||
if(NOT _port_suffix MATCHES "^:(.+)$")
|
||||
set(_valid FALSE)
|
||||
else()
|
||||
_update_client_is_valid_url_port(_port_valid "${CMAKE_MATCH_1}")
|
||||
if(NOT _port_valid)
|
||||
set(_valid FALSE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
if(_valid)
|
||||
_update_client_decode_utf8(_host_codepoints _unused_widths "${_host}")
|
||||
foreach(_codepoint IN LISTS _host_codepoints)
|
||||
if(_codepoint EQUAL 34
|
||||
OR _codepoint EQUAL 35
|
||||
OR _codepoint EQUAL 47
|
||||
OR _codepoint EQUAL 58
|
||||
OR _codepoint EQUAL 60
|
||||
OR _codepoint EQUAL 62
|
||||
OR _codepoint EQUAL 63
|
||||
OR _codepoint EQUAL 64
|
||||
OR _codepoint EQUAL 91
|
||||
OR _codepoint EQUAL 92
|
||||
OR _codepoint EQUAL 93
|
||||
OR _codepoint EQUAL 94
|
||||
OR _codepoint EQUAL 96
|
||||
OR _codepoint EQUAL 123
|
||||
OR _codepoint EQUAL 124
|
||||
OR _codepoint EQUAL 125)
|
||||
set(_valid FALSE)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(NOT _valid)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field 'api_base_url' must be a valid HTTP or HTTPS URL with a non-empty host."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_is_valid_uuid output_variable input_value)
|
||||
set(_uuid "${input_value}")
|
||||
string(TOLOWER "${_uuid}" _uuid_lower)
|
||||
if(_uuid_lower MATCHES "^urn:uuid:")
|
||||
string(SUBSTRING "${_uuid}" 9 -1 _uuid)
|
||||
endif()
|
||||
if(_uuid MATCHES "^\\{.*\\}$")
|
||||
string(LENGTH "${_uuid}" _uuid_length)
|
||||
if(_uuid_length GREATER_EQUAL 2)
|
||||
math(EXPR _inner_length "${_uuid_length} - 2")
|
||||
string(SUBSTRING "${_uuid}" 1 ${_inner_length} _uuid)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_valid FALSE)
|
||||
if(_uuid STREQUAL "")
|
||||
set(_valid TRUE)
|
||||
else()
|
||||
string(LENGTH "${_uuid}" _uuid_length)
|
||||
if(_uuid_length EQUAL 36 AND _uuid MATCHES "^[0-9A-Fa-f-]+$")
|
||||
string(SUBSTRING "${_uuid}" 8 1 _hyphen_1)
|
||||
string(SUBSTRING "${_uuid}" 13 1 _hyphen_2)
|
||||
string(SUBSTRING "${_uuid}" 18 1 _hyphen_3)
|
||||
string(SUBSTRING "${_uuid}" 23 1 _hyphen_4)
|
||||
string(REPLACE "-" "" _uuid_digits "${_uuid}")
|
||||
string(LENGTH "${_uuid_digits}" _digits_length)
|
||||
string(TOLOWER "${_uuid_digits}" _uuid_digits)
|
||||
if(_hyphen_1 STREQUAL "-"
|
||||
AND _hyphen_2 STREQUAL "-"
|
||||
AND _hyphen_3 STREQUAL "-"
|
||||
AND _hyphen_4 STREQUAL "-"
|
||||
AND _digits_length EQUAL 32
|
||||
AND NOT _uuid_digits STREQUAL "00000000000000000000000000000000")
|
||||
set(_valid TRUE)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
set(${output_variable} ${_valid} PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_validate_product_config json_text validation_context)
|
||||
string(JSON _root_type ERROR_VARIABLE _json_error TYPE "${json_text}")
|
||||
if(NOT _json_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR "Invalid JSON in ${validation_context}: ${_json_error}")
|
||||
endif()
|
||||
if(NOT _root_type STREQUAL "OBJECT")
|
||||
message(FATAL_ERROR "Invalid ${validation_context}: root must be a JSON object.")
|
||||
endif()
|
||||
|
||||
string(JSON _schema_type ERROR_VARIABLE _schema_error TYPE "${json_text}" schema_version)
|
||||
if(NOT _schema_error STREQUAL "NOTFOUND" OR NOT _schema_type STREQUAL "NUMBER")
|
||||
message(FATAL_ERROR "Invalid ${validation_context}: schema_version must be the number 1.")
|
||||
endif()
|
||||
string(JSON _schema_version GET "${json_text}" schema_version)
|
||||
if(NOT _schema_version EQUAL 1)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: unsupported schema_version ${_schema_version}."
|
||||
)
|
||||
endif()
|
||||
|
||||
foreach(_required_field IN ITEMS
|
||||
app_id
|
||||
app_name
|
||||
channel
|
||||
launch_token
|
||||
api_base_url
|
||||
client_token
|
||||
temp_folder
|
||||
install_root
|
||||
main_executable
|
||||
launcher_executable
|
||||
updater_executable
|
||||
bootstrap_executable
|
||||
platform
|
||||
arch)
|
||||
_update_client_require_json_string("${json_text}" "${_required_field}")
|
||||
endforeach()
|
||||
foreach(_numeric_field IN ITEMS
|
||||
client_protocol request_timeout_ms health_check_timeout_ms)
|
||||
_update_client_require_positive_integer("${json_text}" "${_numeric_field}")
|
||||
endforeach()
|
||||
|
||||
foreach(_identifier_field IN ITEMS app_id channel platform arch)
|
||||
set(_identifier_value "${UPDATE_CLIENT_CONFIG_${_identifier_field}}")
|
||||
if(NOT _identifier_value MATCHES "^[A-Za-z0-9._-]+$")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: field '${_identifier_field}' contains unsupported characters."
|
||||
)
|
||||
endif()
|
||||
endforeach()
|
||||
_update_client_validate_http_url(
|
||||
"${UPDATE_CLIENT_CONFIG_api_base_url}" "${validation_context}"
|
||||
)
|
||||
_update_client_validate_install_root(
|
||||
_install_root "${UPDATE_CLIENT_CONFIG_install_root}" "${validation_context}"
|
||||
)
|
||||
_update_client_validate_main_executable(
|
||||
"${UPDATE_CLIENT_CONFIG_main_executable}" "${_install_root}" "${validation_context}"
|
||||
)
|
||||
foreach(_path_field IN ITEMS
|
||||
temp_folder launcher_executable updater_executable bootstrap_executable)
|
||||
_update_client_validate_strict_relative_path(
|
||||
"${_path_field}" "${UPDATE_CLIENT_CONFIG_${_path_field}}" "${validation_context}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
foreach(_mutable_field IN ITEMS current_version license_key device_id installation_id)
|
||||
string(JSON _root_mutable_type ERROR_VARIABLE _root_mutable_error
|
||||
TYPE "${json_text}" "${_mutable_field}")
|
||||
if(_root_mutable_error STREQUAL "NOTFOUND")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: mutable field '${_mutable_field}' must be under initial_state."
|
||||
)
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
string(JSON _initial_state_type ERROR_VARIABLE _initial_state_error
|
||||
TYPE "${json_text}" initial_state)
|
||||
if(NOT _initial_state_error STREQUAL "NOTFOUND"
|
||||
OR NOT _initial_state_type STREQUAL "OBJECT")
|
||||
message(FATAL_ERROR "Invalid ${validation_context}: initial_state must be an object.")
|
||||
endif()
|
||||
set(_allowed_initial_state current_version license_key device_id installation_id)
|
||||
set(_has_current_version FALSE)
|
||||
string(JSON _initial_state_length LENGTH "${json_text}" initial_state)
|
||||
if(_initial_state_length GREATER 0)
|
||||
math(EXPR _initial_state_last "${_initial_state_length} - 1")
|
||||
foreach(_index RANGE 0 ${_initial_state_last})
|
||||
string(JSON _initial_key MEMBER "${json_text}" initial_state ${_index})
|
||||
list(FIND _allowed_initial_state "${_initial_key}" _allowed_index)
|
||||
string(JSON _initial_type TYPE "${json_text}" initial_state "${_initial_key}")
|
||||
if(_allowed_index EQUAL -1 OR NOT _initial_type STREQUAL "STRING")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.${_initial_key} is unsupported or is not a string."
|
||||
)
|
||||
endif()
|
||||
|
||||
string(JSON _initial_value GET "${json_text}" initial_state "${_initial_key}")
|
||||
_update_client_qt_trim(_initial_value "${_initial_value}")
|
||||
if(_initial_key STREQUAL "current_version")
|
||||
set(_has_current_version TRUE)
|
||||
string(LENGTH "${_initial_value}" _version_length)
|
||||
if(_version_length GREATER 128
|
||||
OR NOT _initial_value MATCHES "^[0-9A-Za-z][0-9A-Za-z._+-]*$")
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.current_version has an invalid value."
|
||||
)
|
||||
endif()
|
||||
elseif(_initial_key STREQUAL "installation_id")
|
||||
_update_client_is_valid_uuid(_uuid_valid "${_initial_value}")
|
||||
if(NOT _uuid_valid)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.installation_id must be empty or a non-null UUID."
|
||||
)
|
||||
endif()
|
||||
else()
|
||||
_update_client_string_properties(
|
||||
_has_control _unused_space _value_length "${_initial_value}"
|
||||
)
|
||||
if(_initial_key STREQUAL "device_id")
|
||||
set(_maximum_length 256)
|
||||
else()
|
||||
set(_maximum_length 4096)
|
||||
endif()
|
||||
if(_has_control OR _value_length GREATER _maximum_length)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.${_initial_key} contains a control character or exceeds ${_maximum_length} UTF-16 code units."
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
if(NOT _has_current_version)
|
||||
message(FATAL_ERROR
|
||||
"Invalid ${validation_context}: initial_state.current_version is required."
|
||||
)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(_update_client_xml_escape output_variable input_value)
|
||||
set(_escaped "${input_value}")
|
||||
string(REPLACE "&" "&" _escaped "${_escaped}")
|
||||
string(REPLACE "<" "<" _escaped "${_escaped}")
|
||||
string(REPLACE ">" ">" _escaped "${_escaped}")
|
||||
set(${output_variable} "${_escaped}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(_update_client_json_quote output_variable input_value)
|
||||
set(_escaped "${input_value}")
|
||||
string(REPLACE "\\" "\\\\" _escaped "${_escaped}")
|
||||
string(REPLACE "\"" "\\\"" _escaped "${_escaped}")
|
||||
string(REPLACE "\r" "\\r" _escaped "${_escaped}")
|
||||
string(REPLACE "\n" "\\n" _escaped "${_escaped}")
|
||||
string(REPLACE "\t" "\\t" _escaped "${_escaped}")
|
||||
set(${output_variable} "\"${_escaped}\"" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(update_client_select_product_config output_variable)
|
||||
set(_primary_config "${UPDATE_CLIENT_SOURCE_DIR}/config/config.json")
|
||||
set(_fallback_config "${UPDATE_CLIENT_SOURCE_DIR}/config/app_config.example.json")
|
||||
file(GLOB _product_config_candidates CONFIGURE_DEPENDS
|
||||
"${UPDATE_CLIENT_SOURCE_DIR}/config/config.json"
|
||||
"${UPDATE_CLIENT_SOURCE_DIR}/config/app_config.example.json")
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${_primary_config}"
|
||||
"${_fallback_config}"
|
||||
)
|
||||
|
||||
if(UPDATE_CLIENT_PRODUCT_CONFIG_FILE)
|
||||
cmake_path(
|
||||
ABSOLUTE_PATH UPDATE_CLIENT_PRODUCT_CONFIG_FILE
|
||||
BASE_DIRECTORY "${UPDATE_CLIENT_SOURCE_DIR}"
|
||||
NORMALIZE
|
||||
OUTPUT_VARIABLE _selected_config
|
||||
)
|
||||
if(NOT EXISTS "${_selected_config}" OR IS_DIRECTORY "${_selected_config}")
|
||||
message(FATAL_ERROR
|
||||
"UPDATE_CLIENT_PRODUCT_CONFIG_FILE does not exist or is not a file: "
|
||||
"${_selected_config}"
|
||||
)
|
||||
endif()
|
||||
set(_selection_reason "caller-provided UPDATE_CLIENT_PRODUCT_CONFIG_FILE")
|
||||
elseif(EXISTS "${_primary_config}")
|
||||
set(_selected_config "${_primary_config}")
|
||||
set(_selection_reason "project config/config.json")
|
||||
elseif(EXISTS "${_fallback_config}")
|
||||
set(_selected_config "${_fallback_config}")
|
||||
set(_selection_reason "fallback config/app_config.example.json")
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"No update-client product config was found. Create "
|
||||
"${_primary_config} or restore ${_fallback_config}."
|
||||
)
|
||||
endif()
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_selected_config}")
|
||||
set(UPDATE_CLIENT_SELECTED_PRODUCT_CONFIG_FILE
|
||||
"${_selected_config}" CACHE INTERNAL "Selected update-client product config" FORCE)
|
||||
|
||||
file(READ "${_selected_config}" _config_json)
|
||||
_update_client_validate_product_config(
|
||||
"${_config_json}" "selected product config ${_selected_config}"
|
||||
)
|
||||
|
||||
message(STATUS "update-client product config source: ${_selected_config}")
|
||||
message(STATUS "update-client product config selection: ${_selection_reason}")
|
||||
set(${output_variable} "${_selected_config}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(update_client_generate_normalized_product_config output_variable source_file)
|
||||
file(READ "${source_file}" _source_json)
|
||||
string(JSON _normalized_json GET "${_source_json}")
|
||||
|
||||
string(JSON _source_launch_token GET "${_source_json}" launch_token)
|
||||
set(_effective_launch_token "${UPDATE_CLIENT_LAUNCH_TOKEN}")
|
||||
if(_effective_launch_token STREQUAL ""
|
||||
AND _source_launch_token MATCHES "(ChangeMe|CHANGE_ME|YOUR_[A-Z_]+)")
|
||||
if(NOT UPDATE_CLIENT_GENERATED_LAUNCH_TOKEN)
|
||||
string(RANDOM LENGTH 64
|
||||
ALPHABET "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
_generated_launch_token)
|
||||
set(UPDATE_CLIENT_GENERATED_LAUNCH_TOKEN "${_generated_launch_token}"
|
||||
CACHE INTERNAL "Generated update-client launch token" FORCE)
|
||||
endif()
|
||||
set(_effective_launch_token "${UPDATE_CLIENT_GENERATED_LAUNCH_TOKEN}")
|
||||
message(STATUS "Generated a build-local launch token for the example product config")
|
||||
endif()
|
||||
if(NOT _effective_launch_token STREQUAL "")
|
||||
_update_client_json_quote(_launch_token_json "${_effective_launch_token}")
|
||||
string(JSON _normalized_json SET "${_normalized_json}"
|
||||
launch_token "${_launch_token_json}")
|
||||
endif()
|
||||
|
||||
foreach(_override_variable IN ITEMS
|
||||
UPDATE_CLIENT_MAIN_EXECUTABLE
|
||||
UPDATE_CLIENT_LAUNCHER_EXECUTABLE
|
||||
UPDATE_CLIENT_UPDATER_EXECUTABLE
|
||||
UPDATE_CLIENT_BOOTSTRAP_EXECUTABLE
|
||||
UPDATE_CLIENT_PLATFORM
|
||||
UPDATE_CLIENT_ARCH)
|
||||
if("${${_override_variable}}" STREQUAL "")
|
||||
continue()
|
||||
endif()
|
||||
|
||||
string(REGEX REPLACE "^UPDATE_CLIENT_" "" _json_field "${_override_variable}")
|
||||
string(TOLOWER "${_json_field}" _json_field)
|
||||
_update_client_json_quote(_json_value "${${_override_variable}}")
|
||||
string(JSON _normalized_json SET "${_normalized_json}" "${_json_field}" "${_json_value}")
|
||||
message(STATUS
|
||||
"update-client product config override: ${_json_field}=${${_override_variable}}"
|
||||
)
|
||||
endforeach()
|
||||
|
||||
_update_client_validate_product_config(
|
||||
"${_normalized_json}" "normalized product config after build overrides"
|
||||
)
|
||||
|
||||
set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${_generated_dir}")
|
||||
set(_normalized_file "${_generated_dir}/product-config.json")
|
||||
file(WRITE "${_normalized_file}" "${_normalized_json}\n")
|
||||
message(STATUS "update-client normalized product config: ${_normalized_file}")
|
||||
set(${output_variable} "${_normalized_file}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(update_client_create_embedded_resources target_name product_config_file)
|
||||
if(TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Embedded resource target already exists: ${target_name}")
|
||||
endif()
|
||||
|
||||
set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${_generated_dir}")
|
||||
|
||||
cmake_path(CONVERT "${product_config_file}" TO_CMAKE_PATH_LIST _config_qrc_path NORMALIZE)
|
||||
_update_client_xml_escape(_config_qrc_path "${_config_qrc_path}")
|
||||
set(_resource_entries
|
||||
" <file alias=\"product-config.json\">${_config_qrc_path}</file>\n"
|
||||
)
|
||||
|
||||
set(_public_key "${UPDATE_CLIENT_SOURCE_DIR}/config/manifest_public_key.pem")
|
||||
if(NOT EXISTS "${_public_key}")
|
||||
message(FATAL_ERROR
|
||||
"The manifest verification public key is missing: ${_public_key}"
|
||||
)
|
||||
endif()
|
||||
cmake_path(CONVERT "${_public_key}" TO_CMAKE_PATH_LIST _public_key_qrc_path NORMALIZE)
|
||||
_update_client_xml_escape(_public_key_qrc_path "${_public_key_qrc_path}")
|
||||
string(APPEND _resource_entries
|
||||
" <file alias=\"manifest-public-key.pem\">${_public_key_qrc_path}</file>\n"
|
||||
)
|
||||
|
||||
set(_translation_catalog
|
||||
"${UPDATE_CLIENT_SOURCE_DIR}/translations/update-client_zh_CN.ts"
|
||||
)
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${_translation_catalog}")
|
||||
set(_generated_inputs)
|
||||
if(EXISTS "${_translation_catalog}")
|
||||
find_package(Qt5 5.15 CONFIG REQUIRED COMPONENTS LinguistTools)
|
||||
if(NOT TARGET Qt5::lrelease)
|
||||
message(FATAL_ERROR
|
||||
"Qt5::lrelease is required to compile update-client translations."
|
||||
)
|
||||
endif()
|
||||
|
||||
set(_translation_qm "${_generated_dir}/update-client_zh_CN.qm")
|
||||
add_custom_command(
|
||||
OUTPUT "${_translation_qm}"
|
||||
COMMAND Qt5::lrelease "${_translation_catalog}" -qm "${_translation_qm}"
|
||||
DEPENDS "${_translation_catalog}" Qt5::lrelease
|
||||
COMMENT "Compiling update-client Chinese translation catalog"
|
||||
VERBATIM
|
||||
)
|
||||
list(APPEND _generated_inputs "${_translation_qm}")
|
||||
cmake_path(CONVERT "${_translation_qm}" TO_CMAKE_PATH_LIST _translation_qrc_path NORMALIZE)
|
||||
_update_client_xml_escape(_translation_qrc_path "${_translation_qrc_path}")
|
||||
string(APPEND _resource_entries
|
||||
" <file alias=\"i18n/update-client_zh_CN.qm\">${_translation_qrc_path}</file>\n"
|
||||
)
|
||||
elseif(UPDATE_CLIENT_REQUIRE_TRANSLATIONS)
|
||||
message(FATAL_ERROR
|
||||
"Required translation catalog is missing: ${_translation_catalog}"
|
||||
)
|
||||
else()
|
||||
message(STATUS
|
||||
"Translation catalog is not present yet; embedded i18n will be enabled when "
|
||||
"translations/update-client_zh_CN.ts is added."
|
||||
)
|
||||
endif()
|
||||
|
||||
set(_resource_file "${_generated_dir}/update_client_embedded.qrc")
|
||||
string(CONCAT _resource_content
|
||||
"<RCC>\n"
|
||||
" <qresource prefix=\"/simcae/update-client\">\n"
|
||||
"${_resource_entries}"
|
||||
" </qresource>\n"
|
||||
"</RCC>\n"
|
||||
)
|
||||
file(CONFIGURE
|
||||
OUTPUT "${_resource_file}"
|
||||
CONTENT "${_resource_content}"
|
||||
@ONLY
|
||||
)
|
||||
|
||||
if(NOT TARGET Qt5::rcc)
|
||||
message(FATAL_ERROR "Qt5::rcc is required to compile update-client resources.")
|
||||
endif()
|
||||
set(_resource_source "${_generated_dir}/qrc_update_client_embedded.cpp")
|
||||
add_custom_command(
|
||||
OUTPUT "${_resource_source}"
|
||||
COMMAND Qt5::rcc
|
||||
--name update_client_embedded
|
||||
--output "${_resource_source}"
|
||||
"${_resource_file}"
|
||||
DEPENDS
|
||||
"${_resource_file}"
|
||||
"${product_config_file}"
|
||||
"${_public_key}"
|
||||
${_generated_inputs}
|
||||
Qt5::rcc
|
||||
COMMENT "Compiling update-client embedded resources"
|
||||
VERBATIM
|
||||
)
|
||||
add_library("${target_name}" OBJECT "${_resource_source}" ${_generated_inputs})
|
||||
target_link_libraries("${target_name}" PRIVATE Qt5::Core)
|
||||
set_target_properties("${target_name}" PROPERTIES
|
||||
AUTOMOC OFF
|
||||
AUTOUIC OFF
|
||||
AUTORCC OFF
|
||||
)
|
||||
|
||||
set(UPDATE_CLIENT_PRODUCT_CONFIG_RESOURCE
|
||||
":/simcae/update-client/product-config.json"
|
||||
PARENT_SCOPE
|
||||
)
|
||||
set(UPDATE_CLIENT_TRANSLATION_RESOURCE
|
||||
":/simcae/update-client/i18n/update-client_zh_CN.qm"
|
||||
PARENT_SCOPE
|
||||
)
|
||||
endfunction()
|
||||
|
||||
function(update_client_link_embedded_resources target_name)
|
||||
if(NOT TARGET "${target_name}")
|
||||
message(FATAL_ERROR "Unknown update-client target: ${target_name}")
|
||||
endif()
|
||||
if(NOT TARGET UpdateClientEmbeddedResources)
|
||||
message(FATAL_ERROR "UpdateClientEmbeddedResources has not been created.")
|
||||
endif()
|
||||
target_link_libraries("${target_name}" PRIVATE UpdateClientEmbeddedResources)
|
||||
endfunction()
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"app_id": "simcae",
|
||||
"app_name": "SimCAE",
|
||||
"channel": "stable",
|
||||
"client_protocol": "3",
|
||||
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
|
||||
"api_base_url": "http://YOUR_SERVER_IP:8000",
|
||||
"client_token": "SimCAEClientToken2026",
|
||||
"request_timeout_ms": "5000",
|
||||
"temp_folder": "update_temp",
|
||||
"install_root": "..",
|
||||
"main_executable": "SimCAE.exe",
|
||||
"launcher_executable": "Launcher.exe",
|
||||
"updater_executable": "Updater.exe",
|
||||
"bootstrap_executable": "Bootstrap.exe",
|
||||
"health_check_timeout_ms": "15000",
|
||||
"platform": "windows",
|
||||
"arch": "x64",
|
||||
"initial_state": {
|
||||
"current_version": "1.0.0",
|
||||
"license_key": "",
|
||||
"device_id": "",
|
||||
"installation_id": ""
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX1FSi06eP8XhX4B3Oy6
|
||||
FzfkTe3FBIg6OjWpT1541LivRTRt9HXZ30nKuIs5itXBzBQPMU8hsfR0MD9nfPG/
|
||||
NlajfZzqbyxAJlUMeThJiwtyz98wv3VE1hS2Bwuc3mbmtfU0zl/MoPdWrluL3x3C
|
||||
bt0ylL00rLBuvjgG21zDflVqj3w9CwpKHFsNrSYoI6vOFX9YrRZ9tKcPEkSRPqts
|
||||
411QkAQLtMiOMIIhNe5aFIL7doCnglx32hJeHNCTUSxjM9VyHUTEj8wKN/6Gy5iP
|
||||
YZGRafeiJZ32RRIHyssereAg3Xe/3scd+tbFNNYP9xrRhRgDacmkSCBodnbJd4Qc
|
||||
rQIDAQAB
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
|
||||
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
|
||||
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
|
||||
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
|
||||
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
|
||||
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
|
||||
TwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"api_base_url": "http://192.168.1.158:18000"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<RCC>
|
||||
<qresource prefix="/simcae">
|
||||
<file>server_config.json</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
@@ -0,0 +1,162 @@
|
||||
# 更新客户端架构
|
||||
|
||||
## 目的
|
||||
|
||||
更新客户端强制执行一条受控的产品入口路径,并执行事务性更新,而不会将可编辑的安装文件作为信任边界的一部分。父项目 SimCAE 拥有集成和交付的控制权。
|
||||
|
||||
本文档描述了运行时合约。服务器发布、管理员界面、数据库模式和对象存储实现均在此仓库之外。
|
||||
|
||||
## 产品入口规则
|
||||
|
||||
在每个支持的操作系统上,必须通过 `Launcher` 进入发布版本。如果某个平台尚未提供可用的启动器和更新运行时,其发布配置或打包必须失败。直接启动应用程序不是可接受的备用方案。
|
||||
|
||||
调试版本可以禁用启动门以供开发者迭代使用。但带有此例外的构建版本不得分发。
|
||||
|
||||
## 组件
|
||||
|
||||
### 启动器
|
||||
|
||||
`启动器` 是面向用户的入口。它:
|
||||
|
||||
1. 加载嵌入式产品配置和机器范围的可变状态。
|
||||
2. 建立或验证设备凭证。
|
||||
3. 加载并验证已签名的版本策略。
|
||||
4. 检查防回滚、系统时钟倒退和离线有效性条件。
|
||||
5. 将已安装的版本与已签名的清单进行比对验证。
|
||||
6. 在有网络连接时检查服务器是否允许更新。
|
||||
7. 当需要更新时启动 `Updater`。
|
||||
8. 否则创建一个短期有效的一次性票据并启动 SimCAE。
|
||||
|
||||
在需要提升权限才能进行机器范围状态或安装更新的平台上,发布启动器必须通过平台原生机制请求所需权限。
|
||||
|
||||
### 主程序
|
||||
|
||||
交付的 SimCAE 可执行文件实现了合同中的主应用程序部分。此仓库中的 `MainApp` 是一个集成示例,不是第二个产品可执行文件。
|
||||
|
||||
该应用程序必须:
|
||||
|
||||
* 在正常启动前消耗并验证 `--ticket-file=<path>`;
|
||||
* 拒绝在受控发布构建中缺失、过期、重复或不匹配的票证;
|
||||
* 仅在启动达到更新器预期的健康点后,将 `ok\n` 写入 `--health-file=<path>`。
|
||||
|
||||
在当前集成中,启动器拥有已签名策略和已安装文件完整性检查。应用程序独立验证短期票证;将策略或清单验证移至 SimCAE 需要单独的共享运行时协议,且不得由本文件暗示。
|
||||
|
||||
启动票证阻止了对 `Launcher` 的随意绕过。它不是已签名策略、清单验证或服务器授权的替代品。
|
||||
|
||||
### 更新器
|
||||
|
||||
`Updater` 拥有更新事务。它:
|
||||
|
||||
* 获取已签名的目标清单和授权下载位置;
|
||||
* 在进行任何文件操作前验证路径;
|
||||
* 比较本地文件与完整清单;
|
||||
* 仅下载缺失或不匹配的文件;
|
||||
* 在安装前验证签名、大小和哈希值;
|
||||
* 备份被替换或删除的文件;
|
||||
* 安装阶段内容;
|
||||
* 在必要时将锁定文件的替换委托给 `Bootstrap`;
|
||||
* 使用启动票和健康文件请求启动更新后的应用程序;
|
||||
* 仅在健康确认后提交;
|
||||
* 在失败时恢复上一版本。
|
||||
|
||||
下载可以是增量的,但完整性验证始终针对完整的签名清单。
|
||||
|
||||
### 引导启动
|
||||
|
||||
`Bootstrap` 有意设计得非常小。它会等待更新程序进程释放被锁定的文件,执行已批准的替换方案,并重新启动更新程序以继续验证。它必须拒绝不安全的相对路径,且不得擅自制定更新策略。
|
||||
|
||||
### 通用运行时
|
||||
|
||||
共享库提供配置访问、原生状态、设备标识、HTTP 请求、签名策略处理、本地防回滚状态、清单完整性验证以及启动票操作。共享辅助工具不会削弱组件特定的检查。
|
||||
|
||||
## 启动序列
|
||||
|
||||
```text
|
||||
用户
|
||||
-> 启动器
|
||||
-> 嵌入式产品配置
|
||||
-> 机器状态和设备凭证
|
||||
-> 签名策略和清单验证
|
||||
-> 是否需要更新? -> 更新器 -> 需要时启动引导程序
|
||||
-> 创建一次性票据
|
||||
-> SimCAE
|
||||
-> 消耗票据
|
||||
-> 重新验证策略和完整性
|
||||
-> 按请求报告启动健康状态
|
||||
```
|
||||
|
||||
设备激活是机器识别,而不是终端用户登录。普通客户端启动不会创建管理员会话,也不应接收服务器的发布权限。
|
||||
|
||||
## 更新事务
|
||||
|
||||
一个持久的事务必须至少区分这些阶段:
|
||||
|
||||
1. 初始化事务标识符和暂存区。
|
||||
2. 验证目标清单和所有暂存的负载。
|
||||
3. 记录已更改和过时的路径。
|
||||
4. 备份当前文件。
|
||||
5. 安装未被锁定的文件。
|
||||
6. 当需要时,将锁定文件的工作交给 `Bootstrap`。
|
||||
7. 启动候选版本并等待其健康标记。
|
||||
8. 提交并删除临时数据仅在成功验证后进行。
|
||||
9. 在任何失败后回滚并验证恢复的版本。
|
||||
|
||||
在进程启动时,必须在开始新事务之前恢复中断的事务。在持久化状态仍需要恢复时,不得删除暂存和备份数据。
|
||||
|
||||
## 信任边界
|
||||
|
||||
| 数据或机制 | 权限 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 服务器私有签名密钥 | 仅限服务器 | 从未运送到客户。 |
|
||||
| 已签署的版本策略 | 服务器 | 控制运行、更新、降级和离线决策。 |
|
||||
| 签名的清单 | 服务器 | 定义了完整的受保护文件集合和预期的哈希值。 |
|
||||
| 签名的设备凭证 | 服务器 | 绑定应用程序、频道、安装、设备和许可证身份。它不是用户会话。 |
|
||||
| 嵌入式产品配置 | 构建流水线 | 防止随意覆盖安装文件;可由管理员检查和修改。 |
|
||||
| 原生可变状态 | 本地机器 | 保持注册和安装状态;它不涉及授权。 |
|
||||
| 一次性启动票 | 本地启动器 | 证明预期的启动器启动了此进程。它不授予服务器权限。 |
|
||||
| 健康标识 | 候选流程 | 信号提交或回滚的启动完成;它不能证明包的真实性。 |
|
||||
|
||||
带签名的 JSON 合约必须使用确定性序列化,从已签名的负载中省略签名字段,标识算法和密钥 ID,并支持受控的公钥轮换窗口。验证失败将导致系统关闭。
|
||||
|
||||
首次安装可能尚无当前版本的本地清单缓存。当设备在线、当前版本已在服务器显式发布且没有更高目标版本时,Launcher 会通过与 Updater 相同的校验逻辑获取当前版本清单,验证签名和身份后写入缓存,再执行完整文件校验。服务器未发布当前版本、返回无效版本 ID、清单签名无效或缓存写入失败时,启动保持关闭失败。此流程不会自动创建或发布服务器版本。
|
||||
|
||||
## 运行时布局
|
||||
|
||||
父安装程序拥有最终的布局。预期的可执行文件关系为:
|
||||
|
||||
```text
|
||||
<install-root>/
|
||||
bin/
|
||||
Launcher<platform-suffix>
|
||||
Updater<platform-suffix>
|
||||
Bootstrap<platform-suffix>
|
||||
SimCAE<platform-suffix>
|
||||
config/
|
||||
<仅在需要时缓存已签名的运行时>
|
||||
```
|
||||
|
||||
安装目录中不应包含可编辑的产品策略 JSON 文件。更新缓存、备份、已签名策略缓存和机器状态必须使用集成运行时选择的位置,并且必须遵守平台的写入权限规则。
|
||||
|
||||
## 失败策略
|
||||
|
||||
* 无效签名、不安全路径、身份不匹配、策略回滚以及受保护文件损坏将导致系统关闭。
|
||||
* 一个临时的网络故障可能会使用仍然有效的已签名离线策略;它不能静默地创建新的允许项。
|
||||
* 过期的离线策略或被禁止的版本会阻止启动。
|
||||
* 失败的候选健康触发器会导致回滚。
|
||||
* 回滚失败会留下可恢复的状态和诊断错误;它不能报告更新成功。
|
||||
|
||||
## 接受场景
|
||||
|
||||
发布接受必须涵盖:
|
||||
|
||||
* 通过 `Launcher` 正常启动;
|
||||
* 直接 SimCAE 启动拒绝;
|
||||
* 首次设备注册及后续离线启动;
|
||||
* 更新到较新的已发布版本;
|
||||
* 篡改的受保护文件被拒绝;
|
||||
* 篡改的策略或清单被拒绝;
|
||||
* 强制更新和禁用版本行为;
|
||||
* 频道选择和禁止降级行为;
|
||||
* 中断更新恢复;
|
||||
* 候选失败和成功回滚;
|
||||
* 从实际安装位置启动并更新,通过正常的最终用户交互并需要提升权限。
|
||||
@@ -0,0 +1,127 @@
|
||||
# 构建和打包
|
||||
|
||||
## 所有权
|
||||
|
||||
父级 SimCAE 仓库拥有发布配置、运行时部署、安装程序元数据以及最终的用户包。此子项目提供运行时目标和可安装的公共资源。
|
||||
|
||||
支持的包目标是 `package_installer`。它使用父级 CMake 安装规则和 Qt Installer Framework。 `package_installer_qt` 是一个归档的实验性路径,不得使用,也不应作为后备方案进行修复或作为支持的交付选项进行文档说明。
|
||||
|
||||
移除的 `install-sdk.ps1`、`package-client.ps1` 和 `package-sdk.ps1` 工作流不被支持。它们在父级外部组装了部分树 ,这可能导致与产品运行时闭包不一致。
|
||||
|
||||
## 前提条件
|
||||
|
||||
发布构建需要:
|
||||
|
||||
* 与父项目兼容的 CMake 和编译器版本;
|
||||
* 更新客户端目标所请求的 Qt 模块;
|
||||
* 通过 CMake 导入目标发现的 OpenSSL
|
||||
* Qt Installer Framework for `package_installer`;和
|
||||
* 父仓库中批准的 `3rdparty` 包或由调用者明确提供的 CMake 包提示。
|
||||
* 未跟踪的 `config/config.json`,其中包含真实 API 端点和已签发、非空的 `initial_state.license_key`。
|
||||
|
||||
仓库的 CMake 文件不得包含指向工作站的绝对路径,强制 调用者的生成器或架构,或手动选择 Debug 和 Release 库目录。依赖项发现必须生成导入的目标,例如 `Qt5::Core`、`OpenSSL::SSL` 和 `OpenSSL::Crypto`。
|
||||
|
||||
缺少已批准的包必须停止配置并显示可操作的错误。发布包不得静默回退到不相关的系统包。
|
||||
|
||||
## 父级发布构建
|
||||
|
||||
从 SimCAE 仓库根目录运行集成工作流:
|
||||
|
||||
```powershell
|
||||
git submodule update --init --recursive
|
||||
cmake --preset SimCAE-release
|
||||
cmake --build --preset SimCAE-release --target SimCAE
|
||||
```
|
||||
|
||||
专用的发布预设将发布依赖项和输出分开 来自调试信息和旧的混合构建树。集成构建生成 `Launcher`、`Updater` 和 `Bootstrap` 位于 SimCAE 相同的运行目录中,并将发布启动门构建到 SimCAE 中。
|
||||
|
||||
调试开发使用 Debug 预设:
|
||||
|
||||
```powershell
|
||||
cmake --preset SimCAE-debug
|
||||
cmake --build --preset SimCAE-debug --target SimCAE
|
||||
```
|
||||
|
||||
调试模式可能允许开发者直接启动 SimCAE 进行迭代开发。请勿从调试或混合构建树中验证或打包发布版本。
|
||||
|
||||
## 测试
|
||||
|
||||
在打包之前运行父级发布测试:
|
||||
|
||||
```powershell
|
||||
ctest --test-dir out/build/SimCAE-release -C Release --output-on-failure
|
||||
```
|
||||
|
||||
聚焦的更新客户端测试可能运行得更早,但它们不会替代父级测试套件、安装树检查或真实机器验收。
|
||||
|
||||
## 安装程序
|
||||
|
||||
从同一配置的 Release 树构建唯一支持的用户交付版本:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset SimCAE-release --target package_installer
|
||||
```
|
||||
|
||||
该目标通过 `cmake --install` 阶段文件,根据父包定义分离可选的产品组件,并调用 Qt Installer Framework。生成的安装程序以配置的产品版本和平台名称写入 Release 构建目录中。
|
||||
|
||||
服务器发布仍然是一个显式的发布操作。构建安装程序不会创建或发布服务器版本、策略、清单或许可证。
|
||||
|
||||
`package_installer` 会在 staging 之前校验所选产品配置,并拒绝示例端点、空授权或明显的授权占位符。新增或修改 `config/config.json` 后必须重新运行 CMake configure,使 Release 构建选择并嵌入该文件,再执行打包目标。真实授权值只能存在于受控且未跟踪的发布输入中。
|
||||
|
||||
## 独立开发者构建
|
||||
|
||||
一个独立的更新客户端构建对于集中编译和演示测试很有用。它不是最终用户包。
|
||||
|
||||
从父仓库根目录:
|
||||
|
||||
```powershell
|
||||
cmake -S update-client -B out/build/update-client-release
|
||||
cmake --build out/build/update-client-release --config Release `
|
||||
--target Launcher Updater Bootstrap MainApp
|
||||
```
|
||||
|
||||
默认情况下,子项目会在父仓库的 `3rdparty` 目录中查找。对于不同的批准布局,请传递 更新客户端第三方库根目录;标准的包特定 CMake 提示仍可用于专注的开发者构建。请保留所有未提交到 CMake 文件中的值。不要在父应用程序选择的运行时上覆盖第二个 Qt 运行时。
|
||||
|
||||
独立输出是临时的开发者输出。不要将其压缩或发送给用户。
|
||||
|
||||
## 包接受检查清单
|
||||
|
||||
在交付前,请对照干净的安装阶段验证以下所有内容:
|
||||
|
||||
* Launcher,Updater,Bootstrap 和 SimCAE 存在于预期的运行时目录中。
|
||||
* 已安装的用户入口点和快捷方式启动 Launcher,而不是 SimCAE。
|
||||
* 在 Release 版本中拒绝直接启动 SimCAE。
|
||||
* 未安装 app\_config.json,client.ini,源 config.json 或示例产品配置。
|
||||
* 公开验证材料和嵌入式翻译资源存在。
|
||||
* 没有 PDB、ILK、带有 Debug 后缀的 Qt 运行时或其他 Debug 产物。
|
||||
* 在预期的安装路径下存在一个且仅有一个 SimCAE 可执行文件。
|
||||
* 运行时依赖闭包来源于配置的 Release 包;没有 DLL 或共享库解析到开发工作站路径。
|
||||
* 受保护的文件与已签名的当前版本清单文件匹配。如果支持离线首次启动,其已签名的策略和清单缓存都存在且有效。
|
||||
* 开发机器上的可变状态不存在。
|
||||
* 所选发布配置包含已签发的初始授权,且安装树中没有可编辑的授权配置文件。
|
||||
* 在 `Launcher` 旁边放置一个伪造的 `app_config.json` 文件无法更改应用程序身份、渠道、端点、可执行策略或票务验证。
|
||||
* 安装、启动、更新、回滚和卸载均从真实安装位置执行。
|
||||
|
||||
## 运行时兼容性
|
||||
|
||||
更新运行时和 SimCAE 共享已部署的 Qt 和编译器运行时。打包更改不得用不同的 Qt 构建覆盖该运行时。Qt 库中的入口点错误通常表示混合运行时,而不是缺少配置文件。
|
||||
|
||||
对于提升系统安装,首先验证首次注册和状态写入,而不应授予整个应用程序目录的写入权限。运行时状态应存储在本机机器存储或批准的数据位置,而不是放在可执行文件旁边。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 目标包缺失
|
||||
|
||||
确认在配置过程中已找到 Qt Installer Framework,并检查配置输出。不要切换到 `package_installer_qt`。
|
||||
|
||||
### 发布版本加载调试库
|
||||
|
||||
使用专用的发布预设重新配置,并检查所选导入的目标。不要重命名调试二进制文件或在产品树中添加别名。
|
||||
|
||||
### 设备注册无法持久化
|
||||
|
||||
确认 `Launcher` 具备用于机器范围状态的平台权限,并且已安装的构建正在使用原生状态后端。不要将安装目录中的 `app_config.json` 写入作为变通方法。
|
||||
|
||||
### 更新应用程序回滚
|
||||
|
||||
检查更新事务的诊断信息,并确认候选进程在配置的超时时间之前已写入请求的健康标记。绝不要强制提交未经验证的候选进程。
|
||||
@@ -0,0 +1,138 @@
|
||||
# 配置与安全
|
||||
|
||||
## 配置模型
|
||||
|
||||
配置分为两个拥有不同所有者的类别:
|
||||
|
||||
1. **产品配置**是在配置时选择和验证的。它被嵌入到运行时中,并在二进制文件构建完成后变为只读。
|
||||
2. **机器状态**在产品安装过程中会发生变化。它由原生系统范围的后端存储,并且永远不会重新定义产品策略。
|
||||
|
||||
此拆分将可编辑的 `app_config.json` 从已安装的信任路径中移除。
|
||||
|
||||
## 配置时源选择
|
||||
|
||||
自动选择使用以下顺序:
|
||||
|
||||
1. `config/config.json`,当该文件存在时。
|
||||
2. `config/app_config.example.json`,否则。
|
||||
|
||||
集成构建可能会通过 `UPDATE_CLIENT_PRODUCT_CONFIG_FILE` 传递一个确切的文件;该显式输入具有优先权,必须由配置输出报告。清除缓存变量可恢复自动选择。
|
||||
|
||||
`config/config.json` 是一个本地或发布流水线输入。它必须保持未跟踪状态。示例是一个可构建的开发备用方案,而不是生产密钥存储。
|
||||
|
||||
配置必须在所选文件格式错误、缺少必需值、值类型错误或路径违反运行时布局协议时失败。所选的 JSON 文件将被规范化为生成的资源并嵌入到 qrc 中。源 JSON 文件和生成的中间文件不得安装。
|
||||
|
||||
## 产品配置
|
||||
|
||||
产品配置包括定义二进制文件被允许运行的产品的值:
|
||||
|
||||
* application ID 和显示名称;
|
||||
* 发布渠道和客户端协议;
|
||||
* API 基础 URL 和非特权客户端令牌;
|
||||
* 启动票验证材料;
|
||||
* 可执行角色名称和安装布局;
|
||||
* 更新临时目录策略;
|
||||
* 请求和健康检查超时;以及
|
||||
* 目标平台和架构。
|
||||
|
||||
所选的源也包含一个 `initial_state` 对象。它仅提供以下可变键的首次运行默认值。它不会将这些键变为不可变策略,之后的运行时更改仍保留在本地状态存储中。
|
||||
|
||||
发布者必须在未跟踪的 `config/config.json` 中提供已签发且非空的 `initial_state.license_key`。示例配置中的空值只支持开发构建;父级 `package_installer` 会拒绝缺失、空值或明显占位符。该值会作为初始状态嵌入二进制文件,并在系统级授权状态缺失或为空时由 Launcher 写入;已有的非空机器授权不会被构建默认值覆盖。授权不会作为可编辑的安装目录 JSON 交付。
|
||||
|
||||
这些值可能在不同构建之间有所不同,但安装的文件不得覆盖它们。在可执行文件旁边放置名为 `app_config.json`、`client.ini` 或 `config.json` 的文件,不是受支持的运行时覆盖。
|
||||
|
||||
可执行文件路径必须是相对的、规范化的,并且限定在批准的安装根目录内。它们不得包含遍历段,也不得指向产品外部的任意可执行文件。
|
||||
|
||||
## 可变机器状态
|
||||
|
||||
机器状态包括:
|
||||
|
||||
* 当前安装的版本;
|
||||
* 许可证注册值;
|
||||
* 安装和设备标识符;
|
||||
* 迁移完成标记;
|
||||
* 单调更新或策略状态未包含在已签名的缓存中。
|
||||
|
||||
Qt 客户端在系统范围内使用原生后端存储这些值,并禁用回退查找。在 Windows 上,这是机器级别的注册表位置。其他平台使用等效的原生系统位置。当前逻辑命名空间是 `SimCAE/UpdateClient`,产品状态如下 `products/<app-id>/<channel>/state/`,因此所有更新客户端进程都访问同一个存储,而不会混合产品或频道。
|
||||
|
||||
发布启动必须具有读取和更新此状态所需的平台权限。未能持久化所需状态会导致启动错误;不得触发回退到可执行目录 JSON。
|
||||
|
||||
已签名的策略、清单和设备凭证缓存,在其格式需要时可以使用经批准的数据文件。此类文件是运行时缓存,而非产品配置,其签名在使用前必须进行验证。
|
||||
|
||||
## 版本和发布边界
|
||||
|
||||
已安装的版本是本地状态。服务器版本、通道、策略和发布清单是通过服务器工作流程显式发布的。本地构建、Git 版本、头信息值或安装程序文件名不得自动发布或授权服务器发布。
|
||||
|
||||
客户端只能在提交并验证的更新事务的一部分中报告其已安装的版本并将其向前推进。
|
||||
|
||||
## 安全属性
|
||||
|
||||
### 嵌入数据不是机密
|
||||
|
||||
qrc 可防止对侧边 JSON 文件进行随意编辑。它不会阻止机器管理员进行二进制检查或修改。因此,客户端中嵌入的值不得包含服务器发布或管理权限。
|
||||
|
||||
API 客户端令牌和启动票据材料是客户端凭证。服务器端点仍必须对每个操作进行身份验证,并独立授权设备、许可证、频道和版本。
|
||||
|
||||
### 原生状态不是信任根
|
||||
|
||||
系统范围的原生存储提高了所有权和访问控制,尤其是在系统安装中。管理员仍然可以对其进行修改。本地版本、许可证文本或迁移标志不能替代服务器验证。
|
||||
|
||||
### 签名的服务器数据具有权威性
|
||||
|
||||
服务器私钥从不会离开服务器。客户端仅接收公开验证材料。客户端会验证所有适用的签名工件,包括:
|
||||
|
||||
* 版本策略;
|
||||
* 完整文件清单;
|
||||
* 设备凭证;和
|
||||
* 离线更新元数据,如支持。
|
||||
|
||||
签名的有效负载标识其使用的算法和密钥 ID,并采用确定性的序列化方式。密钥轮换可能会保留当前和立即前一个公钥,以便在受控的迁移窗口期内进行过渡。未知密钥或无效签名将关闭失败。
|
||||
|
||||
### 设备身份不等于用户登录
|
||||
|
||||
设备发行将应用程序、频道、安装、设备和许可证绑定在一起。它不会创建终端用户会话,也不会授予对管理发布接口的访问权限。
|
||||
|
||||
### 启动票证作用范围
|
||||
|
||||
一次性票券绑定预期的应用、设备、版本和较短的有效期。应用会一次性消耗该票券。票券验证阻止普通直接启动,但不会使策略或完整性检查变得可选。
|
||||
|
||||
### 完整性与回滚
|
||||
|
||||
更新程序可能仅下载更改的文件,但受保护的安装会针对完整的签名清单进行验证。在文件操作前会检查路径。先前版本会在候选版本健康状态确认之前进行备份,并在另一次更新前恢复中断的事务。
|
||||
|
||||
## 公开验证材料
|
||||
|
||||
公钥不是秘密。它们可以作为显式的公共运行时资产嵌入或安装,前提是包拥有其来源,并且客户端不会在没有其他可信锚点的情况下接受任意替换。私钥和服务器管理凭证绝不能进入此仓库或安装程序。
|
||||
|
||||
## 发布包规则
|
||||
|
||||
已安装的树中不得包含:
|
||||
|
||||
* app\_config.json;
|
||||
* `client.ini`;
|
||||
* source `config.json`;
|
||||
* `app_config.example.json`;
|
||||
* license 或 device state 从构建机器复制过来; 或
|
||||
* 生成的产品配置中间件。
|
||||
|
||||
该包必须包含验证服务器合约所需的二进制文件和公共资源。父目标 `package_installer` 拥有这些安装规则。
|
||||
|
||||
## 必要的篡改检查
|
||||
|
||||
发布验证必须证明:
|
||||
|
||||
1. 添加或更改侧边车 JSON 不会改变嵌入式应用程序 ID、频道、端点、可执行文件名称或票证行为。
|
||||
2. 更改本地状态无法伪造有效的服务器签名或获取发布权限。
|
||||
3. 更改受保护的二进制文件或库会被清单验证检测到。
|
||||
4. 重放或编辑启动票证将被拒绝。
|
||||
5. 用较旧的已签名序列替换策略或清单会被反回滚状态拒绝。
|
||||
6. 候选启动失败会导致回滚,而不是版本状态的前进。
|
||||
|
||||
## 操作指南
|
||||
|
||||
* 请将特定版本的 `config/config.json` 保留在版本控制之外,并通过受控的构建环境提供。
|
||||
* 每次发布都要在该文件中配置已签发的 `initial_state.license_key`;不要把真实授权值写入示例、文档或版本控制。
|
||||
* 限制对发布构建输入的访问,但不要将客户端侧的值描述为不可提取的秘密。
|
||||
* 通过明确的发布流程轮换服务器签名密钥和客户端令牌。
|
||||
* 在原生后端和权限边界处诊断状态写入失败。不要使安装目录广泛可写。
|
||||
* 将更改的端点、频道、可执行文件名称或启动票据密钥视为新的二进制配置,需要重新构建和包验证。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 本地化维护
|
||||
|
||||
## 政策
|
||||
|
||||
英文是生产代码的源语言。用户可见的中文文本只能出现在本地化资源中。生产代码中的 C、C++、头文件和 CMake 文件不得在消息、注释、目标标签或诊断信息中包含汉字。
|
||||
|
||||
协议字段、JSON 键、命令行开关、日志标识符、错误代码和文件格式标记是稳定的接口,不应进行翻译。
|
||||
|
||||
## Qt 应用程序
|
||||
|
||||
`Launcher`、`Updater` 以及 `MainApp` 集成示例使用 Qt 翻译。
|
||||
|
||||
* 在 `QObject` 子类中使用 `tr()`,当该类提供预期的翻译上下文时。
|
||||
* 在自由函数、静态辅助函数和入口点代码中使用 `QCoreApplication::translate("StableContext", "English source")`。
|
||||
* 保持上下文名称稳定。重命名上下文会使现有的翻译条目失效。
|
||||
* 使用英文源文本作为回退语言。
|
||||
* 保持简体中文翻译目录作为已提交的 `.ts` 源文件。
|
||||
* 通过 CMake 将 `.ts` 编译为 `.qm`,并将编译后的目录嵌入到 qrc 中。
|
||||
* 安装不可松动、用户可替换的翻译文件,当构建合同需要嵌入式 UI 资源时。
|
||||
* 仅在 `Common/TranslationHelper` 中保留 qrc 目录路径。应用程序入口点不得构造其他资源路径或加载第二个翻译器。
|
||||
* 在构建 UI 对象或格式化启动错误之前初始化 `TranslationHelper`。它仅在匹配的语言环境时安装嵌入式目录;其他语言环境使用英文源文本。
|
||||
* `TranslationHelper` 还会从部署的运行时 `translations/` 目录加载 Qt 的匹配基础目录,以便标准对话框按钮使用相同语言环境。这是独立的 Qt 运行时目录,不是替代应用程序目录路径。
|
||||
|
||||
不要仅仅为了满足扫描而将字符串放在翻译调用中。每个用户可见的消息都需要在目录中有一个条目,并且需要经过审核的翻译。
|
||||
|
||||
## 引导启动
|
||||
|
||||
`Bootstrap` 仍然独立于 Qt。在 Windows 上,可本地化的 UI 文本应放在 Win32 `STRINGTABLE` 资源中,并通过资源标识符加载。英语是默认的资源语言。
|
||||
|
||||
其他支持的平台必须使用等效的平台资源机制或经过审核的资源表。不要重新引入源语言条件语句或硬编码的本地化字面量。
|
||||
|
||||
机器可读的诊断信息可以保持为稳定的英文文本。任何在对话框中显示的文本都是用户可见的,必须使用资源表。
|
||||
|
||||
## 添加或修改文本
|
||||
|
||||
1. 编写简洁的英文源字符串。
|
||||
2. 选择一个现有的稳定上下文或添加一个名称明确的上下文。
|
||||
3. 在每次翻译中保留占位符,如 `%1`、`%2`、换行符和标记。
|
||||
4. 通过 CMake 管理的提取步骤更新翻译目录。
|
||||
5. 翻译并审阅已更改的条目。
|
||||
6. 构建嵌入式翻译资源。
|
||||
7. 启用英文备用和简体中文界面路径。
|
||||
8. 运行无汉字的源语检查。
|
||||
|
||||
避免通过连接翻译片段来构建句子。词序和复数规则因语言而异。翻译完整的信息,并通过占位符传递可变内容。
|
||||
|
||||
## 错误来源
|
||||
|
||||
错误跨越多个边界,需要不同的处理方式:
|
||||
|
||||
| 源文本 | 处理方式 |
|
||||
| --- | --- |
|
||||
| 本地用户界面决策 | 翻译完整的用户可见消息。 |
|
||||
| 本地技术诊断 | 保持稳定的英文细节,并将其封装在翻译后的用户可见摘要中。 |
|
||||
| 服务器错误代码 | 将稳定的代码映射到本地翻译后的消息;不要将协议 JSON 作为主要用户界面显示。 |
|
||||
| 操作系统错误 | 保留原生细节以供诊断,并提供一个翻译后的行动导向摘要。 |
|
||||
| 仅记录事件 | 除非也展示给用户,否则保持英文稳定。 |
|
||||
|
||||
不要将本地化文本作为错误代码或事务状态发送回服务器。
|
||||
|
||||
在迁移过程中,旧的服务器响应可能仍需要匹配翻译后的短语。请重用 `TranslationHelper` 已拥有的目录;不要为分类加载第二个目录。当该 API 可用时,用稳定的服务器错误代码替换文本匹配。
|
||||
|
||||
## 目录和资源审核
|
||||
|
||||
在合并 i18n 变更之前,请确认:
|
||||
|
||||
* 每个修改过的用户可见源字符串都出现在目录中;
|
||||
* 主启动/更新流程中没有未完成的条目;
|
||||
* 占位符在源字符串和翻译之间完全匹配;
|
||||
* 加速器标记和富文本标记保持有效;
|
||||
* the `.qm` 输出被嵌入到每个使用它的应用程序中;
|
||||
* 启动、提升、更新、回滚和致命错误对话框都已涵盖;
|
||||
* 不可用的语言环境会回退到英文,且不显示空白文本;
|
||||
* 生成的 `.qm` 路径不依赖于开发者的开发工作站。
|
||||
|
||||
## 源代码扫描范围
|
||||
|
||||
自动的源代码检查应包括生产代码:
|
||||
|
||||
* `*.c`, `*.cc`, `*.cpp`,以及 `*.h`;
|
||||
* `CMakeLists.txt` 和 `*.cmake`;和
|
||||
* 平台源资源,除已批准的本地化表之外。
|
||||
|
||||
它应排除翻译目录、已批准的 `STRINGTABLE` 资源、文档、第三方代码、生成的文件和构建输出。排除项必须明确,以便新添加的生产目录默认被检查。
|
||||
|
||||
## 运行时验证
|
||||
|
||||
自动目录检查是必要的但不够的。发布验收必须至少检查以下内容:
|
||||
|
||||
* 首次注册和凭证错误;
|
||||
* 离线策略和强制更新信息;
|
||||
* 更新下载、验证和回滚失败;
|
||||
* 提升失败或取消;
|
||||
* 直接启动拒绝;以及
|
||||
* 引导程序替换失败。
|
||||
|
||||
请确认文本与实际对话框相符,并且切换操作系统区域设置不会改变协议行为或配置选择。
|
||||
@@ -0,0 +1,129 @@
|
||||
# Legacy 配置迁移
|
||||
|
||||
## 范围
|
||||
|
||||
较旧的 update-client 构建版本会读取并重写可执行文件旁边的 product 数据。在升级安装中,可能存在两种格式:
|
||||
|
||||
* `config/app_config.json`; 和
|
||||
* client.ini
|
||||
|
||||
新版本使用嵌入式产品配置加上本地机器范围的状态存储。旧文件仅作为迁移输入,它们永远不会覆盖嵌入式应用程序标识、服务器端点、通道、可执行策略或票据配置。
|
||||
|
||||
此迁移与配置时的 `config/config.json` 无关。后者是受控的源代码树或构建流水线输入,从不会从安装中读取。
|
||||
|
||||
## 迁移优先级
|
||||
|
||||
迁移仅在原生状态存储尚未为此产品安装完成迁移时运行。
|
||||
|
||||
1. 现有有效的原生状态胜利,且不读取遗留文件。
|
||||
2. 否则,导入允许的可变值 `config/app_config.json` 在存在且有效时。
|
||||
3. 从 `client.ini` 中填充仍缺失的允许值(当存在时)。
|
||||
4. 验证导入的值并将它们原子性地持久化到原生存储中。
|
||||
5. 仅在状态写入成功后记录迁移完成。
|
||||
|
||||
迁移必须是幂等的。对旧文件的后续编辑不应改变已迁移的安装。
|
||||
|
||||
## 允许的值
|
||||
|
||||
只能导入可变的安装状态:
|
||||
|
||||
| 旧值 | 新所有者 | 迁移规则 |
|
||||
| --- | --- | --- |
|
||||
| current\_version | 原生安装版本状态 | 从未签名的旧文件中导入;从嵌入的构建状态初始化,并且仅通过经过验证的更新事务进行升级。 |
|
||||
| license\_key | Native enrollment state | Import as enrollment input; the server still validates it. |
|
||||
| device\_id | 原生设备状态 | 当有有效签名设备凭证时,优先使用其值。 |
|
||||
| installation\_id | 原生安装状态 | 仅在语法有效时导入;否则通过当前状态服务生成。 |
|
||||
| 单调策略/更新标记 | 原生状态或签名缓存 | 仅通过专用验证导入;永远不要信任未签名的较低序列。 |
|
||||
|
||||
迁移时不得将这些遗留值作为运行时覆盖导入:
|
||||
|
||||
* `app_id` 或 `app_name`;
|
||||
* `channel` 或 `client_protocol`;
|
||||
* `api_base_url` 或 `client_token`;
|
||||
* launch\_token;
|
||||
* 可执行文件名或 `install_root`;
|
||||
* 临时目录布局;
|
||||
* 平台或架构;或
|
||||
* 请求和健康检查策略。
|
||||
|
||||
这些值只能来自构建期间选择的嵌入式产品配置。
|
||||
|
||||
## 旧版 INI 映射参考
|
||||
|
||||
旧的 INI 格式将值分组如下。此表仅用于支持有限的迁移阅读器和法医诊断。
|
||||
|
||||
| Section | Keys previously used |
|
||||
| --- | --- |
|
||||
| App | app\_id, app\_name, channel, current\_version, client\_protocol, launch\_token |
|
||||
| 许可协议 | license\_key |
|
||||
| 服务器 | api\_base\_url, client\_token |
|
||||
| 更新 | request\_timeout\_ms, temp\_folder, device\_id |
|
||||
| 设备 | installation\_id |
|
||||
| 运行时 | install\_root, main\_executable, launcher\_executable, updater\_executable, bootstrap\_executable, health\_check\_timeout\_ms |
|
||||
|
||||
只有上述可变子集可能离开此解析器。该表不会将其他键识别为支持的设置。
|
||||
|
||||
## 验证与失败
|
||||
|
||||
* 解析旧版 JSON 和 INI 文件而不更改文件内容。
|
||||
* 在编写本地状态之前,应用严格的长度和格式检查。
|
||||
* 不要将未签名的策略序列向后复制。
|
||||
* 不要将遗留路径接受为可执行文件或更新根。
|
||||
* 将所有导入的状态原子地写入,然后设置迁移完成标记。
|
||||
* 如果持久化失败,请报告一个可操作的错误并保留迁移未完成状态,以便在权限或存储问题解决后可以重试。
|
||||
* 不要在每次启动时都回退到读取旧文件。
|
||||
|
||||
格式错误的旧文件不得阻止干净安装与当前产品配置的注册。保留足够的诊断信息以识别被拒绝的来源,但不要显示许可证材料或客户端令牌。
|
||||
|
||||
## 已签名的旧文件
|
||||
|
||||
旧的安装可能还包含:
|
||||
|
||||
* `config/client_identity.dat`;
|
||||
* `config/version_policy.dat`;
|
||||
* `config/local_state.json`; 和
|
||||
* update/manifest\_cache/
|
||||
|
||||
这些不是产品配置。只有在当前签名、身份、通道、版本、过期时间和防回滚检查通过后,才能重复使用已签名的设备凭证、策略或清单。未签名的本地状态只是一个迁移提示,不能降低已记录的序列号或延长离线有效性。
|
||||
|
||||
迁移实现应将接受的状态移动到当前批准的存储或缓存位置。它不得继续将可执行目录视为可写运行时数据库。
|
||||
|
||||
## 打包规则
|
||||
|
||||
父级 `package_installer` 输出中不得包含遗留配置或状态。特别是,不要打包:
|
||||
|
||||
* app\_config.json;
|
||||
* `client.ini`;
|
||||
* 开发机器的设备凭证;
|
||||
* 本地策略状态;
|
||||
* 交易状态;或
|
||||
* 更新下载和备份目录。
|
||||
|
||||
存在迁移代码用于从旧版本升级,而不是为了维持旧的软件包结构。
|
||||
|
||||
## 操作规程
|
||||
|
||||
对于现有安装:
|
||||
|
||||
1. 通过受支持的安装程序/更新路径安装已签名的新包。
|
||||
2. 通过 `Launcher` 启动,并使用原生机器状态所需的平台权限。
|
||||
3. 确认设备注册、策略验证和已安装版本状态。
|
||||
4. 确认第二次启动成功,无需查阅已更改的旧文件。
|
||||
5. 确认直接应用启动仍被阻止。
|
||||
|
||||
迁移成功后,旧文件可以归档用于事件分析,或通过批准的清理路径删除。它们的持续存在不应影响运行时策略。
|
||||
|
||||
## 迁移测试
|
||||
|
||||
覆盖至少:
|
||||
|
||||
* 无遗留文件的干净安装;
|
||||
* 仅 JSON 迁移;
|
||||
* 仅 INI 迁移;
|
||||
* JSON 优先,INI 作为缺失可变值的后备;
|
||||
* 现有原生状态带有恶意的遗留覆盖;
|
||||
* 格式错误的 JSON 和格式错误的 INI 值;
|
||||
* 无效或过期的签名遗留凭证;
|
||||
* 原生状态写入失败后成功重试;
|
||||
* 迁移完成后重复启动;以及
|
||||
* 迁移后的侧车配置被篡改。
|
||||
-176
@@ -1,176 +0,0 @@
|
||||
客户端国际化说明
|
||||
================
|
||||
|
||||
这份文档说明 Qt 国际化文件怎么维护,以及哪些步骤是自动的、哪些步骤需要你手动做。
|
||||
|
||||
先看结论
|
||||
========
|
||||
|
||||
Visual Studio 和 PowerShell 二选一即可。
|
||||
|
||||
- Visual Studio 是图形界面入口。
|
||||
- PowerShell 是命令行入口。
|
||||
- 两者最终调用的是同一套 CMake 目标,不是两套流程。
|
||||
|
||||
最重要的规则:
|
||||
|
||||
1. 普通“全部重新生成”会自动把已有的 update-client_zh_CN.ts 编译成 update-client_zh_CN.qm。
|
||||
2. 普通“全部重新生成”不会自动扫描源码生成新的 update-client_zh_CN.ts 条目。
|
||||
3. 如果新增了 QObject::tr(...)、QCoreApplication::translate(...) 这类新文案,必须先手动生成一次 update_client_lupdate。
|
||||
4. 你编辑完 update-client_zh_CN.ts 后,再“全部重新生成”,CMake 会自动生成 .qm,并通过 qrc 打进程序。
|
||||
|
||||
文件说明
|
||||
========
|
||||
|
||||
1. update-client_zh_CN.ts
|
||||
翻译源文件,XML 格式。新增或修改代码里的 tr()/translate() 文案后,需要更新这个文件,再补中文翻译。
|
||||
|
||||
2. update-client_zh_CN.qm
|
||||
Qt 运行时加载的二进制翻译文件。它由 .ts 编译生成,不要手工编辑。
|
||||
|
||||
3. update-client.qrc
|
||||
Qt 资源文件。它会把 update-client_zh_CN.qm 编进 Launcher、Updater、Bootstrap、MainApp,不需要把 .qm 单独放到安装目录。
|
||||
|
||||
日常编译:没有新增界面文字
|
||||
==========================
|
||||
|
||||
这种情况最简单。
|
||||
|
||||
你只是改了普通 C++ 代码,或者只是修改了 update-client_zh_CN.ts 里已有条目的中文翻译:
|
||||
|
||||
Visual Studio:
|
||||
|
||||
```text
|
||||
选择 x64 Release 或 x64 Debug -> 全部重新生成
|
||||
```
|
||||
|
||||
PowerShell 等价命令:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\admin\Desktop\update-client
|
||||
cmake --build --preset x64-release
|
||||
```
|
||||
|
||||
这时 CMake 会自动执行 lrelease:
|
||||
|
||||
```text
|
||||
update-client_zh_CN.ts -> update-client_zh_CN.qm
|
||||
```
|
||||
|
||||
然后 .qm 会通过 update-client.qrc 打进 exe。
|
||||
|
||||
新增界面文字后的完整流程
|
||||
========================
|
||||
|
||||
如果代码里新增了这些文字:
|
||||
|
||||
```cpp
|
||||
QObject::tr("New message")
|
||||
QCoreApplication::translate("Context", "New message")
|
||||
```
|
||||
|
||||
只点“全部重新生成”是不够的。因为“全部重新生成”不会自动扫描源码,把新 source 写进 .ts。
|
||||
|
||||
正确流程是:
|
||||
|
||||
1. 先更新 .ts 文件。
|
||||
|
||||
Visual Studio:
|
||||
|
||||
```text
|
||||
在 CMake 目标里找到 update_client_lupdate,然后生成这个目标。
|
||||
```
|
||||
|
||||
PowerShell 等价命令:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\admin\Desktop\update-client
|
||||
cmake --build --preset x64-release --target update_client_lupdate
|
||||
```
|
||||
|
||||
这一步会扫描 Common、Bootstrap、Launcher、Updater、MainApp 里的 cpp/h 文件,把新增的 tr()/translate() 文案写入:
|
||||
|
||||
```text
|
||||
i18n/update-client_zh_CN.ts
|
||||
```
|
||||
|
||||
2. 编辑 update-client_zh_CN.ts。
|
||||
|
||||
找到新增的 `<source>...</source>`,把对应 `<translation>...</translation>` 补成中文。
|
||||
|
||||
可以用 Qt Linguist 打开,也可以直接用文本编辑器编辑 XML。
|
||||
|
||||
3. 再重新生成程序。
|
||||
|
||||
Visual Studio:
|
||||
|
||||
```text
|
||||
全部重新生成
|
||||
```
|
||||
|
||||
PowerShell 等价命令:
|
||||
|
||||
```powershell
|
||||
cmake --build --preset x64-release
|
||||
```
|
||||
|
||||
这一步会自动做:
|
||||
|
||||
```text
|
||||
update-client_zh_CN.ts -> update-client_zh_CN.qm -> update-client.qrc -> exe
|
||||
```
|
||||
|
||||
如果只想单独生成 .qm
|
||||
====================
|
||||
|
||||
一般不需要单独做。普通编译会自动生成 .qm。
|
||||
|
||||
如果你只是想检查 .ts 能不能正常编译成 .qm,可以单独生成这个目标:
|
||||
|
||||
Visual Studio:
|
||||
|
||||
```text
|
||||
生成 CMake 目标 update_client_translations
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\admin\Desktop\update-client
|
||||
cmake --build --preset x64-release --target update_client_translations
|
||||
```
|
||||
|
||||
常见问题
|
||||
========
|
||||
|
||||
1. 新增了 tr(),为什么程序里没有中文?
|
||||
|
||||
通常是少做了 update_client_lupdate。新增文案后必须先更新 .ts,再补中文,再重新生成。
|
||||
|
||||
2. 我只改了 .ts 里的中文,还要跑 update_client_lupdate 吗?
|
||||
|
||||
不需要。直接“全部重新生成”即可,CMake 会自动重新生成 .qm。
|
||||
|
||||
3. update-client_zh_CN.qm 要不要交付到安装目录?
|
||||
|
||||
不需要。它已经通过 update-client.qrc 编进 exe。
|
||||
|
||||
4. 代码里能不能直接写中文?
|
||||
|
||||
不建议。界面文字统一写英文 source,然后在 .ts 里翻译成中文。
|
||||
|
||||
5. Visual Studio 或 CMake 找不到 lupdate / lrelease 怎么办?
|
||||
|
||||
通常是 Qt 环境变量没配好。确认 CMAKE_PREFIX_PATH 或 Qt5_DIR 指向 Qt 目录。
|
||||
|
||||
Windows 示例:
|
||||
|
||||
```powershell
|
||||
[Environment]::SetEnvironmentVariable("CMAKE_PREFIX_PATH", "C:\Qt\5.15.2\msvc2019_64", "User")
|
||||
```
|
||||
|
||||
Linux 示例:
|
||||
|
||||
```bash
|
||||
sudo apt install -y qttools5-dev-tools
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
<RCC>
|
||||
<qresource prefix="/i18n">
|
||||
<file>update-client_zh_CN.qm</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
||||
SimCAE Hub 客户端脚本说明
|
||||
==========================
|
||||
|
||||
本目录保存 Qt/C++ 客户端更新链路的辅助脚本。客户端仍然由
|
||||
Launcher、Updater、Bootstrap 和业务主程序组成,服务端接口使用当前
|
||||
SimCAE Hub 的 Go API。
|
||||
|
||||
脚本列表:
|
||||
|
||||
1. package-sdk.ps1
|
||||
在 Windows 上生成给业务软件接入用的客户端更新运行时包。
|
||||
|
||||
2. package-client.ps1
|
||||
Windows 本地调试用的客户包脚本,需要手工提供 app_config.json。
|
||||
新流程建议上传完整软件 ZIP 到 SimCAE Hub,由服务端生成最终配置。
|
||||
|
||||
3. install-sdk.ps1
|
||||
把客户端更新运行时复制到业务软件 Release 目录。
|
||||
|
||||
4. package-sdk.sh
|
||||
在 Linux 上生成客户端更新运行时包,输出 tar.gz。
|
||||
|
||||
5. package-client.sh
|
||||
Linux 本地调试用的客户包脚本,需要手工提供 app_config.json。
|
||||
|
||||
SDK 打包命令、两种打包模式、参数含义和输出位置,统一看:
|
||||
|
||||
../打包成SDK.md
|
||||
|
||||
生成 SDK 后,把 Launcher、Updater、Bootstrap 和必要运行库放进业务软件
|
||||
根目录或 bin 目录,再把完整软件目录压缩上传到 SimCAE Hub 管理后台的
|
||||
发布包页面。服务端会生成 config/app_config.json,并在启用 Manifest 签名
|
||||
时生成 config/manifest_public_key.pem。
|
||||
@@ -1,60 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$SdkRoot,
|
||||
|
||||
[string]$ReleaseDir = (Get-Location).Path,
|
||||
|
||||
[switch]$IncludeQtRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$sdk = (Resolve-Path $SdkRoot).Path
|
||||
$release = (Resolve-Path $ReleaseDir).Path
|
||||
|
||||
$binDir = Join-Path $sdk "bin"
|
||||
|
||||
foreach ($path in @($binDir)) {
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "SDK file is missing: $path"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsQtRuntimeItem {
|
||||
param([System.IO.FileSystemInfo]$Item)
|
||||
|
||||
if ($IncludeQtRuntime) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($Item.PSIsContainer) {
|
||||
return $Item.Name -in @(
|
||||
"bearer",
|
||||
"iconengines",
|
||||
"imageformats",
|
||||
"platforms",
|
||||
"styles",
|
||||
"translations"
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
$Item.Name -match '^Qt5.*\.dll$' -or
|
||||
$Item.Name -in @(
|
||||
"libEGL.dll",
|
||||
"libGLESv2.dll",
|
||||
"opengl32sw.dll",
|
||||
"d3dcompiler_47.dll"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Get-ChildItem $binDir -Force | Where-Object {
|
||||
-not (Test-IsQtRuntimeItem $_)
|
||||
} | Copy-Item -Destination $release -Recurse -Force
|
||||
|
||||
$targetConfigDir = Join-Path $release "config"
|
||||
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
Write-Host "SDK files installed to: $release"
|
||||
Write-Host "Next: package the whole application directory and upload it in SimCAE Hub. The server will generate config/app_config.json and config/manifest_public_key.pem when needed."
|
||||
@@ -1,175 +0,0 @@
|
||||
param(
|
||||
[string]$SourceDir = "",
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigFile,
|
||||
[string]$OutputDir = "",
|
||||
[string]$ZipFile = "",
|
||||
[switch]$SkipManifestCheck
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
|
||||
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
||||
$OutputDir = Join-Path $RepoRoot "dist/SimCAEUpdateClient"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
|
||||
$ZipFile = Join-Path $RepoRoot "dist/SimCAEUpdateClient.zip"
|
||||
}
|
||||
|
||||
$source = (Resolve-Path $SourceDir).Path
|
||||
$config = (Resolve-Path $ConfigFile).Path
|
||||
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
|
||||
function Normalize-RelativePath([string]$PathValue) {
|
||||
return ($PathValue -replace '\\', '/').Trim('/')
|
||||
}
|
||||
|
||||
function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) {
|
||||
$full = (Resolve-Path $FullPath).Path
|
||||
if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/'))
|
||||
}
|
||||
return Normalize-RelativePath $Fallback
|
||||
}
|
||||
|
||||
$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json"
|
||||
$configRelativeParent = Split-Path $configRelativePath -Parent
|
||||
$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent)
|
||||
if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" }
|
||||
|
||||
function Join-RelativePath([string]$Base, [string]$Child) {
|
||||
$baseNorm = Normalize-RelativePath $Base
|
||||
$childNorm = Normalize-RelativePath $Child
|
||||
if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm }
|
||||
if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm }
|
||||
return "$baseNorm/$childNorm"
|
||||
}
|
||||
|
||||
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 "SimCAE\HubUpdateClient\installations\$installId\update\manifest_cache\$ManifestName"
|
||||
}
|
||||
|
||||
$requiredFields = @(
|
||||
"product_code", "channel", "current_version", "launch_token",
|
||||
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
|
||||
)
|
||||
foreach ($field in $requiredFields) {
|
||||
if (-not $settings.$field) {
|
||||
throw "Config file is missing required field: $field"
|
||||
}
|
||||
}
|
||||
|
||||
$mainExecutable = Normalize-RelativePath $settings.main_executable
|
||||
$launcherExecutable = Normalize-RelativePath $settings.launcher_executable
|
||||
$updaterExecutable = Normalize-RelativePath $settings.updater_executable
|
||||
$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable
|
||||
|
||||
$requiredFiles = @(
|
||||
(Join-RelativePath $runtimeDirRelative $mainExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $launcherExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $updaterExecutable),
|
||||
(Join-RelativePath $runtimeDirRelative $bootstrapExecutable)
|
||||
)
|
||||
foreach ($name in $requiredFiles) {
|
||||
$nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar
|
||||
if (-not (Test-Path (Join-Path $source $nativeName))) {
|
||||
throw "Source directory is missing required file: $name"
|
||||
}
|
||||
}
|
||||
|
||||
$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 the Release output directory and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
|
||||
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
||||
$mainLeafName = Split-Path $mainExecutable -Leaf
|
||||
$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object {
|
||||
$relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/'))
|
||||
$relative -ne $expectedMainPath
|
||||
} | Select-Object -First 1
|
||||
if ($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"
|
||||
$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 $SkipManifestCheck -and (-not $sourceManifest -or -not (Test-Path $sourceManifest))) {
|
||||
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
|
||||
throw "Missing Manifest cache for current version: $manifestName. Complete online update/verification for this version before packaging, or pass -SkipManifestCheck for a first-time test package. Searched paths:$([Environment]::NewLine)$searched"
|
||||
}
|
||||
|
||||
if (Test-Path $OutputDir) {
|
||||
Remove-Item $OutputDir -Recurse -Force
|
||||
}
|
||||
New-Item $OutputDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
Get-ChildItem $source -Force | Where-Object {
|
||||
$_.Name -notin @("update", "update_temp")
|
||||
} | Copy-Item -Destination $OutputDir -Recurse -Force
|
||||
|
||||
$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if (Test-Path $outputRuntimeUpdateDir) {
|
||||
Remove-Item $outputRuntimeUpdateDir -Recurse -Force
|
||||
}
|
||||
$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if (Test-Path $outputRuntimeTempDir) {
|
||||
Remove-Item $outputRuntimeTempDir -Recurse -Force
|
||||
}
|
||||
|
||||
$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
$outputConfigDir = Split-Path $outputConfigPath -Parent
|
||||
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
|
||||
Copy-Item $config $outputConfigPath -Force
|
||||
|
||||
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
|
||||
$runtimeFile = Join-Path $outputConfigDir $_
|
||||
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force }
|
||||
}
|
||||
|
||||
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
||||
if ($sourceManifest -and (Test-Path $sourceManifest)) {
|
||||
New-Item $manifestDir -ItemType Directory -Force | Out-Null
|
||||
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
||||
}
|
||||
|
||||
$zipParent = Split-Path $ZipFile -Parent
|
||||
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
||||
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
|
||||
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
|
||||
|
||||
Write-Host "Package directory: $OutputDir"
|
||||
Write-Host "ZIP file: $ZipFile"
|
||||
@@ -1,234 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
|
||||
CONFIG_FILE=""
|
||||
OUTPUT_DIR="$REPO_ROOT/dist/SimCAEUpdateClient-linux"
|
||||
ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEUpdateClient-linux.tar.gz"
|
||||
SKIP_MANIFEST_CHECK=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: package-client.sh --config-file FILE [options]
|
||||
|
||||
Options:
|
||||
--source-dir DIR Release/install root to package. Default: ./out/linux/bin
|
||||
--config-file FILE app_config.json used by this client package. Required.
|
||||
--output-dir DIR Output directory. Default: ./dist/SimCAEUpdateClient-linux
|
||||
--archive FILE Output tar.gz. Default: ./dist/SimCAEUpdateClient-linux.tar.gz
|
||||
--skip-manifest-check Skip current-version manifest cache check.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
normalize_relative_path() {
|
||||
local value="${1:-}"
|
||||
value="${value//\\//}"
|
||||
value="${value#/}"
|
||||
value="${value%/}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
join_relative_path() {
|
||||
local base
|
||||
local child
|
||||
base="$(normalize_relative_path "${1:-}")"
|
||||
child="$(normalize_relative_path "${2:-}")"
|
||||
if [[ -z "$base" ]]; then printf '%s' "$child"; return; fi
|
||||
if [[ -z "$child" ]]; then printf '%s' "$base"; return; fi
|
||||
printf '%s/%s' "$base" "$child"
|
||||
}
|
||||
|
||||
json_value() {
|
||||
python3 - "$CONFIG_FILE" "$1" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
value = data.get(sys.argv[2], "")
|
||||
print("" if value is None else value)
|
||||
PY
|
||||
}
|
||||
|
||||
relative_to_source() {
|
||||
local full="$1"
|
||||
local fallback="$2"
|
||||
python3 - "$SOURCE_DIR" "$full" "$fallback" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
source = os.path.realpath(sys.argv[1])
|
||||
full = os.path.realpath(sys.argv[2])
|
||||
fallback = sys.argv[3]
|
||||
try:
|
||||
rel = os.path.relpath(full, source)
|
||||
except ValueError:
|
||||
rel = fallback
|
||||
if rel.startswith(".."):
|
||||
rel = fallback
|
||||
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/SimCAE/HubUpdateClient/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 ;;
|
||||
--config-file) CONFIG_FILE="$2"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
|
||||
--skip-manifest-check) SKIP_MANIFEST_CHECK=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$CONFIG_FILE" ]]; then
|
||||
echo "--config-file is required." >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
|
||||
CONFIG_FILE="$(realpath "$CONFIG_FILE")"
|
||||
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
|
||||
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
|
||||
|
||||
for field in product_code channel current_version launch_token main_executable launcher_executable updater_executable bootstrap_executable; do
|
||||
if [[ -z "$(json_value "$field")" ]]; then
|
||||
echo "Config file is missing required field: $field" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
CONFIG_RELATIVE_PATH="$(relative_to_source "$CONFIG_FILE" "config/app_config.json")"
|
||||
CONFIG_RELATIVE_PARENT="$(dirname "$CONFIG_RELATIVE_PATH")"
|
||||
[[ "$CONFIG_RELATIVE_PARENT" == "." ]] && CONFIG_RELATIVE_PARENT=""
|
||||
RUNTIME_DIR_RELATIVE="$(normalize_relative_path "$(dirname "$CONFIG_RELATIVE_PARENT")")"
|
||||
[[ "$RUNTIME_DIR_RELATIVE" == "." ]] && RUNTIME_DIR_RELATIVE=""
|
||||
|
||||
MAIN_EXECUTABLE="$(normalize_relative_path "$(json_value main_executable)")"
|
||||
LAUNCHER_EXECUTABLE="$(normalize_relative_path "$(json_value launcher_executable)")"
|
||||
UPDATER_EXECUTABLE="$(normalize_relative_path "$(json_value updater_executable)")"
|
||||
BOOTSTRAP_EXECUTABLE="$(normalize_relative_path "$(json_value bootstrap_executable)")"
|
||||
CURRENT_VERSION="$(json_value current_version)"
|
||||
|
||||
for required in \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
|
||||
"$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")"; do
|
||||
if [[ ! -f "$SOURCE_DIR/$required" ]]; then
|
||||
echo "Source directory is missing required file: $required" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
|
||||
if [[ -n "$DEBUG_ARTIFACT" ]]; then
|
||||
echo "Source directory contains Debug artifacts. Use a clean Release root directory." >&2
|
||||
echo "Example: $DEBUG_ARTIFACT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_MAIN_PATH="$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")"
|
||||
MAIN_LEAF="$(basename "$MAIN_EXECUTABLE")"
|
||||
DUPLICATE_MAIN="$({ find "$SOURCE_DIR" -type f -name "$MAIN_LEAF" | while read -r item; do
|
||||
rel="$(python3 - "$SOURCE_DIR" "$item" <<'PY'
|
||||
import os, sys
|
||||
print(os.path.relpath(os.path.realpath(sys.argv[2]), os.path.realpath(sys.argv[1])).replace(os.sep, "/"))
|
||||
PY
|
||||
)"
|
||||
[[ "$rel" != "$EXPECTED_MAIN_PATH" ]] && { echo "$item"; break; }
|
||||
done; } || true)"
|
||||
if [[ -n "$DUPLICATE_MAIN" ]]; then
|
||||
echo "Source directory contains a duplicate main executable outside $EXPECTED_MAIN_PATH: $DUPLICATE_MAIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MANIFEST_NAME="manifest_${CURRENT_VERSION}.json"
|
||||
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 Manifest cache for current version: $MANIFEST_NAME." >&2
|
||||
echo "Complete online update/verification for this version before packaging, or pass --skip-manifest-check for a first-time test package. Searched paths:" >&2
|
||||
printf ' - %s\n' "${MANIFEST_CANDIDATES[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
shopt -s dotglob nullglob
|
||||
for item in "$SOURCE_DIR"/*; do
|
||||
base="$(basename "$item")"
|
||||
case "$base" in
|
||||
update|update_temp) ;;
|
||||
*) cp -a "$item" "$OUTPUT_DIR/" ;;
|
||||
esac
|
||||
done
|
||||
shopt -u dotglob nullglob
|
||||
|
||||
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update")"
|
||||
rm -rf "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update_temp")"
|
||||
|
||||
OUTPUT_CONFIG_PATH="$OUTPUT_DIR/$CONFIG_RELATIVE_PATH"
|
||||
mkdir -p "$(dirname "$OUTPUT_CONFIG_PATH")"
|
||||
cp "$CONFIG_FILE" "$OUTPUT_CONFIG_PATH"
|
||||
rm -f "$(dirname "$OUTPUT_CONFIG_PATH")/client_identity.dat" \
|
||||
"$(dirname "$OUTPUT_CONFIG_PATH")/local_state.json" \
|
||||
"$(dirname "$OUTPUT_CONFIG_PATH")/version_policy.dat"
|
||||
|
||||
if [[ -f "$SOURCE_MANIFEST" ]]; then
|
||||
MANIFEST_DIR="$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "update/manifest_cache")"
|
||||
mkdir -p "$MANIFEST_DIR"
|
||||
cp "$SOURCE_MANIFEST" "$MANIFEST_DIR/$MANIFEST_NAME"
|
||||
fi
|
||||
|
||||
chmod +x "$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$LAUNCHER_EXECUTABLE")" \
|
||||
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$UPDATER_EXECUTABLE")" \
|
||||
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$BOOTSTRAP_EXECUTABLE")" \
|
||||
"$OUTPUT_DIR/$(join_relative_path "$RUNTIME_DIR_RELATIVE" "$MAIN_EXECUTABLE")" 2>/dev/null || true
|
||||
|
||||
mkdir -p "$(dirname "$ARCHIVE_FILE")"
|
||||
rm -f "$ARCHIVE_FILE"
|
||||
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
|
||||
|
||||
echo "Package directory: $OUTPUT_DIR"
|
||||
echo "Archive file: $ARCHIVE_FILE"
|
||||
@@ -1,152 +0,0 @@
|
||||
param(
|
||||
[string]$SourceDir = "",
|
||||
[string]$OutputDir = "",
|
||||
[string]$ZipFile = "",
|
||||
[string]$SdkVersion = "0.1.0",
|
||||
[switch]$IncludeDemoMainApp,
|
||||
[switch]$IncludeQtRuntime
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
if ([string]::IsNullOrWhiteSpace($SourceDir)) {
|
||||
$SourceDir = Join-Path $RepoRoot "out/bin/Release"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($OutputDir)) {
|
||||
$OutputDir = Join-Path $RepoRoot "dist/SimCAEHubUpdateClientSDK"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($ZipFile)) {
|
||||
$ZipFile = Join-Path $RepoRoot "dist/SimCAEHubUpdateClientSDK.zip"
|
||||
}
|
||||
|
||||
$source = (Resolve-Path $SourceDir).Path
|
||||
|
||||
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
|
||||
foreach ($name in $requiredFiles) {
|
||||
$path = Join-Path $source $name
|
||||
if (-not (Test-Path $path)) {
|
||||
throw "SDK source directory is missing required file: $path"
|
||||
}
|
||||
}
|
||||
|
||||
$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 "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)"
|
||||
}
|
||||
|
||||
if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force }
|
||||
New-Item $OutputDir -ItemType Directory -Force | Out-Null
|
||||
|
||||
$binDir = Join-Path $OutputDir "bin"
|
||||
$configDir = Join-Path $OutputDir "config"
|
||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||
$commonDir = Join-Path $OutputDir "Common"
|
||||
$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" }
|
||||
|
||||
if (-not $IncludeQtRuntime) {
|
||||
$excludedTopLevel += @(
|
||||
"bearer",
|
||||
"iconengines",
|
||||
"imageformats",
|
||||
"platforms",
|
||||
"styles",
|
||||
"translations"
|
||||
)
|
||||
}
|
||||
|
||||
function Test-IsQtRuntimeFile {
|
||||
param([System.IO.FileSystemInfo]$Item)
|
||||
|
||||
if ($IncludeQtRuntime -or $Item.PSIsContainer) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return (
|
||||
$Item.Name -match '^Qt5.*\.dll$' -or
|
||||
$Item.Name -match '^vc_redist.*\.exe$' -or
|
||||
$Item.Name -match '^vcredist.*\.exe$' -or
|
||||
$Item.Name -match '^vcruntime.*\.dll$' -or
|
||||
$Item.Name -match '^msvcp.*\.dll$' -or
|
||||
$Item.Name -match '^concrt.*\.dll$' -or
|
||||
$Item.Name -in @(
|
||||
"libEGL.dll",
|
||||
"libGLESv2.dll",
|
||||
"opengl32sw.dll",
|
||||
"d3dcompiler_47.dll"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Get-ChildItem $source -Force | Where-Object {
|
||||
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
|
||||
} | Copy-Item -Destination $binDir -Recurse -Force
|
||||
|
||||
$commonSourceDir = Join-Path $RepoRoot "Common"
|
||||
$commonSourceFiles = @(
|
||||
"ConfigHelper.h",
|
||||
"ConfigHelper.cpp",
|
||||
"IntegrityHelper.h",
|
||||
"IntegrityHelper.cpp",
|
||||
"TicketHelper.h",
|
||||
"TicketHelper.cpp",
|
||||
"UpdatePathPolicy.h",
|
||||
"UpdatePathPolicy.cpp"
|
||||
)
|
||||
foreach ($commonFile in $commonSourceFiles) {
|
||||
$commonPath = Join-Path $commonSourceDir $commonFile
|
||||
if (-not (Test-Path $commonPath)) {
|
||||
throw "SDK Common integration source is missing: $commonPath"
|
||||
}
|
||||
Copy-Item $commonPath (Join-Path $commonDir $commonFile) -Force
|
||||
}
|
||||
|
||||
$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 ($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 (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
|
||||
Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force
|
||||
|
||||
@{
|
||||
sdk_version = $SdkVersion
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||
sdk_type = "external-updater-runtime"
|
||||
required_entry = "Launcher.exe"
|
||||
contains_demo_main_app = [bool]$IncludeDemoMainApp
|
||||
contains_qt_runtime = [bool]$IncludeQtRuntime
|
||||
contains_final_config = $false
|
||||
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
|
||||
|
||||
$zipParent = Split-Path $ZipFile -Parent
|
||||
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
||||
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
|
||||
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
|
||||
|
||||
Write-Host "SDK directory: $OutputDir"
|
||||
Write-Host "SDK zip: $ZipFile"
|
||||
Write-Host "SDK version: $SdkVersion"
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
SOURCE_DIR="$REPO_ROOT/out/linux/bin"
|
||||
OUTPUT_DIR="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-linux"
|
||||
ARCHIVE_FILE="$REPO_ROOT/dist/SimCAEHubUpdateClientSDK-linux.tar.gz"
|
||||
SDK_VERSION="0.1.0"
|
||||
INCLUDE_DEMO_MAIN_APP=0
|
||||
INCLUDE_QT_RUNTIME=0
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: package-sdk.sh [options]
|
||||
|
||||
Options:
|
||||
--source-dir DIR Linux Release output directory. Default: ./out/linux/bin
|
||||
--output-dir DIR SDK directory to generate. Default: ./dist/SimCAEHubUpdateClientSDK-linux
|
||||
--archive FILE SDK tar.gz path. Default: ./dist/SimCAEHubUpdateClientSDK-linux.tar.gz
|
||||
--sdk-version VERSION SDK version. Default: 0.1.0
|
||||
--include-demo-mainapp Include MainApp demo executable in SDK bin.
|
||||
--include-qt-runtime Include Qt runtime files from the Release output directory.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--source-dir) SOURCE_DIR="$2"; shift 2 ;;
|
||||
--output-dir) OUTPUT_DIR="$2"; shift 2 ;;
|
||||
--archive|--tar-file|--zip-file) ARCHIVE_FILE="$2"; shift 2 ;;
|
||||
--sdk-version) SDK_VERSION="$2"; shift 2 ;;
|
||||
--include-demo-mainapp) INCLUDE_DEMO_MAIN_APP=1; shift ;;
|
||||
--include-qt-runtime) INCLUDE_QT_RUNTIME=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SOURCE_DIR="$(realpath "$SOURCE_DIR")"
|
||||
OUTPUT_DIR="$(realpath -m "$OUTPUT_DIR")"
|
||||
ARCHIVE_FILE="$(realpath -m "$ARCHIVE_FILE")"
|
||||
|
||||
for name in Launcher Updater Bootstrap; do
|
||||
if [[ ! -f "$SOURCE_DIR/$name" ]]; then
|
||||
echo "SDK source directory is missing required file: $SOURCE_DIR/$name" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
DEBUG_ARTIFACT="$(find "$SOURCE_DIR" -type f \( -name '*.pdb' -o -name '*.ilk' -o -name '*d.dll' \) -print -quit)"
|
||||
if [[ -n "$DEBUG_ARTIFACT" ]]; then
|
||||
echo "SDK source directory contains Debug artifacts. Use a clean Release output directory." >&2
|
||||
echo "Example: $DEBUG_ARTIFACT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR/bin" "$OUTPUT_DIR/config" "$OUTPUT_DIR/scripts" "$OUTPUT_DIR/Common" "$OUTPUT_DIR/Docs"
|
||||
|
||||
is_qt_runtime_item() {
|
||||
local base="$1"
|
||||
case "$base" in
|
||||
bearer|iconengines|imageformats|platforms|styles|translations)
|
||||
return 0
|
||||
;;
|
||||
libQt5*.so*|libEGL.so*|libGLESv2.so*|libqxcb.so*|libxcb*.so*|libstdc++.so*|libgcc_s.so*|libssl.so*|libcrypto.so*|opengl32sw.dll|d3dcompiler_47.dll)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
shopt -s dotglob nullglob
|
||||
for item in "$SOURCE_DIR"/*; do
|
||||
base="$(basename "$item")"
|
||||
case "$base" in
|
||||
config|update|update_temp|manifest_public_key.pem|MainApp)
|
||||
if [[ "$base" == "MainApp" && "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]]; then
|
||||
cp -a "$item" "$OUTPUT_DIR/bin/"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
if [[ "$INCLUDE_QT_RUNTIME" -eq 0 ]] && is_qt_runtime_item "$base"; then
|
||||
continue
|
||||
fi
|
||||
cp -a "$item" "$OUTPUT_DIR/bin/"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shopt -u dotglob nullglob
|
||||
|
||||
for common_file in ConfigHelper.h ConfigHelper.cpp IntegrityHelper.h IntegrityHelper.cpp TicketHelper.h TicketHelper.cpp UpdatePathPolicy.h UpdatePathPolicy.cpp; do
|
||||
common_path="$REPO_ROOT/Common/$common_file"
|
||||
if [[ ! -f "$common_path" ]]; then
|
||||
echo "SDK Common integration source is missing: $common_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$common_path" "$OUTPUT_DIR/Common/$common_file"
|
||||
done
|
||||
|
||||
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 "$SCRIPT_DIR/package-sdk.sh" "$OUTPUT_DIR/scripts/package-sdk.sh"
|
||||
cp "$SCRIPT_DIR/package-client.sh" "$OUTPUT_DIR/scripts/package-client.sh"
|
||||
if [[ -f "$SCRIPT_DIR/install-sdk.ps1" ]]; then cp "$SCRIPT_DIR/install-sdk.ps1" "$OUTPUT_DIR/scripts/install-sdk.ps1"; fi
|
||||
if [[ -f "$SCRIPT_DIR/package-sdk.ps1" ]]; then cp "$SCRIPT_DIR/package-sdk.ps1" "$OUTPUT_DIR/scripts/package-sdk.ps1"; fi
|
||||
if [[ -f "$SCRIPT_DIR/package-client.ps1" ]]; then cp "$SCRIPT_DIR/package-client.ps1" "$OUTPUT_DIR/scripts/package-client.ps1"; fi
|
||||
|
||||
chmod +x "$OUTPUT_DIR/bin/Launcher" "$OUTPUT_DIR/bin/Updater" "$OUTPUT_DIR/bin/Bootstrap" 2>/dev/null || true
|
||||
chmod +x "$OUTPUT_DIR/scripts/package-sdk.sh" "$OUTPUT_DIR/scripts/package-client.sh"
|
||||
|
||||
cat > "$OUTPUT_DIR/sdk_manifest.json" <<EOF
|
||||
{
|
||||
"sdk_version": "$SDK_VERSION",
|
||||
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
||||
"sdk_type": "external-updater-runtime",
|
||||
"platform": "linux",
|
||||
"required_entry": "Launcher",
|
||||
"contains_demo_main_app": $([[ "$INCLUDE_DEMO_MAIN_APP" -eq 1 ]] && echo true || echo false),
|
||||
"contains_qt_runtime": $([[ "$INCLUDE_QT_RUNTIME" -eq 1 ]] && echo true || echo false),
|
||||
"contains_final_config": false,
|
||||
"docs_entry": "Docs/01-客户端接入打包部署指南.md",
|
||||
"word_guide_included": $WORD_GUIDE_INCLUDED,
|
||||
"integration_sources": [
|
||||
"ConfigHelper.h",
|
||||
"ConfigHelper.cpp",
|
||||
"IntegrityHelper.h",
|
||||
"IntegrityHelper.cpp",
|
||||
"TicketHelper.h",
|
||||
"TicketHelper.cpp",
|
||||
"UpdatePathPolicy.h",
|
||||
"UpdatePathPolicy.cpp"
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p "$(dirname "$ARCHIVE_FILE")"
|
||||
rm -f "$ARCHIVE_FILE"
|
||||
tar -C "$OUTPUT_DIR" -czf "$ARCHIVE_FILE" .
|
||||
|
||||
echo "SDK directory: $OUTPUT_DIR"
|
||||
echo "SDK archive: $ARCHIVE_FILE"
|
||||
echo "SDK version: $SDK_VERSION"
|
||||
@@ -0,0 +1,822 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE TS>
|
||||
<TS version="2.1" language="zh_CN" sourcelanguage="en_US">
|
||||
<context>
|
||||
<name>Launcher</name>
|
||||
<message>
|
||||
<source>Checking for software updates...</source>
|
||||
<translation>正在检查软件更新...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Marsco Launcher</source>
|
||||
<translation>Marsco 软件启动器</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>application</source>
|
||||
<translation>软件</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enter the license for %1:</source>
|
||||
<translation>请输入%1授权 License:</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>%1
|
||||
|
||||
Please re-enter the license for %2:</source>
|
||||
<translation>%1
|
||||
|
||||
请重新输入%2授权 License:</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Enter License</source>
|
||||
<translation>输入 License</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Required</source>
|
||||
<translation>需要 License</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A license provided by an administrator is required for the first launch.</source>
|
||||
<translation>首次启动需要输入管理员提供的 License。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Cannot Be Empty</source>
|
||||
<translation>License 不能为空</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Paste the license created by an administrator.</source>
|
||||
<translation>请粘贴管理员创建的 License。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Failed to Save License</source>
|
||||
<translation>保存 License 失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save system state at %1:
|
||||
%2</source>
|
||||
<translation>无法在 %1 保存系统状态:
|
||||
%2</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Verifying license...</source>
|
||||
<translation>正在验证 License...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Device Authentication Failed</source>
|
||||
<translation>设备身份验证失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current license cannot be used: %1</source>
|
||||
<translation>当前 License 无法使用:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The device or license authorization is invalid.</source>
|
||||
<translation>设备或 License 授权无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The server rejected the current license: %1</source>
|
||||
<translation>当前 License 被服务端拒绝:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Saved</source>
|
||||
<translation>License 已保存</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Restart Launcher to complete device authorization and check for updates.</source>
|
||||
<translation>请重新启动 Launcher 完成设备授权和更新检查。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Authorization Denied</source>
|
||||
<translation>授权被拒绝</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Select Offline Update Package</source>
|
||||
<translation>选择离线更新包</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Marsco Offline Update Package (*.upd)</source>
|
||||
<translation>Marsco 离线更新包 (*.upd)</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Update</source>
|
||||
<translation>离线更新</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No offline update package was selected.</source>
|
||||
<translation>未选择离线更新包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the local run policy...</source>
|
||||
<translation>正在验证本地运行策略...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Unable to Start</source>
|
||||
<translation>无法启动</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local version policy is invalid: %1</source>
|
||||
<translation>本地版本策略无效:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local version policy has expired. Connect to the network or contact an administrator.</source>
|
||||
<translation>本地版本策略已过期,请连接网络或联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Current Version Cannot Run</source>
|
||||
<translation>当前版本不可运行</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version %1 has been disabled by an administrator.</source>
|
||||
<translation>当前版本 %1 已被管理员停用。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot read the local state: %1</source>
|
||||
<translation>无法读取本地状态:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Security Check Failed</source>
|
||||
<translation>安全检查失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A version policy sequence rollback was detected, so startup was blocked.</source>
|
||||
<translation>检测到版本策略序列回退,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A possible system clock rollback was detected, so startup was blocked.</source>
|
||||
<translation>检测到系统时间可能被回拨,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version Rollback</source>
|
||||
<translation>版本回退</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Required</source>
|
||||
<translation>必须更新</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>New Version Available</source>
|
||||
<translation>发现新版本</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>An administrator selected version %1 as the rollback target. Downgrade now?</source>
|
||||
<translation>管理员选择版本 %1 作为回退目标,是否现在降级?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version %1 is available. Update now?</source>
|
||||
<translation>发现新版本 %1,是否现在更新?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>
|
||||
Target version: %1</source>
|
||||
<translation>
|
||||
目标版本:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Failed</source>
|
||||
<translation>启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot start the main application: %1</source>
|
||||
<translation>无法启动主程序:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Updater Startup Failed</source>
|
||||
<translation>更新器启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot start the updater: %1</source>
|
||||
<translation>无法启动更新器:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Server Unavailable</source>
|
||||
<translation>服务器不可用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update server is currently unreachable. Import an offline update package?</source>
|
||||
<translation>当前无法连接更新服务器。是否导入离线更新包?</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No offline update package was selected, or the updater could not be started.</source>
|
||||
<translation>未选择离线更新包或无法启动更新器。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Network Unavailable</source>
|
||||
<translation>网络不可用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update server is unreachable and the current policy does not allow offline startup.</source>
|
||||
<translation>无法连接更新服务器,且当前策略不允许离线启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current version is up to date. Starting...</source>
|
||||
<translation>当前已是最新版本,正在启动...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline mode is active. Starting...</source>
|
||||
<translation>当前处于离线模式,正在启动...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Integrity Check Failed</source>
|
||||
<translation>完整性检查失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Application files failed signed manifest verification:
|
||||
%1</source>
|
||||
<translation>应用文件未通过签名清单验证:
|
||||
%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed manifest for version %1 could not be fetched or cached.</source>
|
||||
<translation>无法获取或缓存版本 %1 的签名清单。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Version %1 is not published on the update server. Publish this exact installed build before distribution so its signed manifest can be verified.</source>
|
||||
<translation>版本 %1 尚未发布到更新服务器。请在分发前发布当前安装构建,以便验证其签名清单。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>LauncherErrorMarker</name>
|
||||
<message>
|
||||
<source>authorization</source>
|
||||
<translation>授权</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>expired</source>
|
||||
<translation>过期</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>device limit reached</source>
|
||||
<translation>设备数量已达到上限</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>bound to another license</source>
|
||||
<translation>已绑定其他授权</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MainApp</name>
|
||||
<message>
|
||||
<source>Startup Restriction</source>
|
||||
<translation>启动受限</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Invalid or missing one-time launch ticket: %1
|
||||
Please use %2.</source>
|
||||
<translation>一次性启动票据无效或缺失:%1
|
||||
请通过 %2 启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>License Error</source>
|
||||
<translation>授权错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Local license invalid: %1</source>
|
||||
<translation>本地授权无效:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Policy Error</source>
|
||||
<translation>策略错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Local policy invalid: %1</source>
|
||||
<translation>本地策略无效:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Policy expired. The application cannot start.</source>
|
||||
<translation>策略已过期,应用程序无法启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>State Error</source>
|
||||
<translation>状态错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot load local state: %1</source>
|
||||
<translation>无法加载本地状态:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>A policy sequence rollback was detected, so startup was blocked.</source>
|
||||
<translation>检测到策略序列回退,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The system clock appears to have moved backward, so startup was blocked.</source>
|
||||
<translation>检测到系统时间可能被回拨,已阻止启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Integrity Check Failed</source>
|
||||
<translation>完整性检查失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Application files failed signed manifest verification:
|
||||
%1</source>
|
||||
<translation>应用程序文件未通过签名 Manifest 校验:
|
||||
%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save local state: %1</source>
|
||||
<translation>无法保存本地状态:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Error</source>
|
||||
<translation>启动错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot write the update health confirmation file.</source>
|
||||
<translation>无法写入更新健康确认文件。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>MainWindow</name>
|
||||
<message>
|
||||
<source>missing</source>
|
||||
<translation>缺失</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>unreadable</source>
|
||||
<translation>无法读取</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Marsco Demo MainApp</source>
|
||||
<translation>Marsco 主程序演示</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>unknown</source>
|
||||
<translation>未知</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy missing</source>
|
||||
<translation>策略缺失</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy invalid</source>
|
||||
<translation>策略无效</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>version disabled</source>
|
||||
<translation>版本已停用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy expired</source>
|
||||
<translation>策略已过期</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>policy ok</source>
|
||||
<translation>策略有效</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Software Running Successfully
|
||||
Version: %1
|
||||
DLL Version: %2
|
||||
Policy: %3</source>
|
||||
<translation>软件运行成功
|
||||
版本:%1
|
||||
DLL 版本:%2
|
||||
策略:%3</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed policy does not allow this installed version to run.</source>
|
||||
<translation>签名策略不允许运行当前安装版本。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>Updater</name>
|
||||
<message>
|
||||
<source>Invalid Offline Package</source>
|
||||
<translation>离线包无效</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Invalid Updater Arguments</source>
|
||||
<translation>更新器参数错误</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The updater requires online update arguments or an offline update package.</source>
|
||||
<translation>更新器缺少在线更新参数或离线更新包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Package Not Applicable</source>
|
||||
<translation>离线包不适用</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update package application or channel does not match this installation.</source>
|
||||
<translation>更新包的应用或渠道与本机配置不一致。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Update Denied</source>
|
||||
<translation>离线更新被拒绝</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local signed policy has expired, prohibits offline updates, or does not allow the target version.</source>
|
||||
<translation>本地签名策略已过期、禁止离线更新或不允许目标版本。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Downgrade Prohibited</source>
|
||||
<translation>禁止降级</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current signed policy does not allow installing an older offline package.</source>
|
||||
<translation>当前签名策略不允许安装较低版本的离线包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Recovery Failed</source>
|
||||
<translation>更新恢复失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The previous update did not finish, and the old version could not be restored: %1
|
||||
Do not continue running the software. Contact an administrator.</source>
|
||||
<translation>检测到上次更新未完成,但无法恢复旧版本:%1
|
||||
请不要继续运行软件,并联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The old files were restored, but the version state could not be restored. Check access to the system state store.</source>
|
||||
<translation>旧文件已经恢复,但无法恢复版本状态,请检查系统状态存储的访问权限。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Preparing the update...</source>
|
||||
<translation>正在准备更新...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Updating to %1</source>
|
||||
<translation>正在更新到 %1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Preparing the download...</source>
|
||||
<translation>正在准备下载...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Downloading: %1</source>
|
||||
<translation>正在下载:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Handing rollback over to Bootstrap...</source>
|
||||
<translation>正在将回滚工作移交给 Bootstrap...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>
|
||||
|
||||
Bootstrap could not be started to perform the rollback. Do not continue running the software. Contact an administrator.</source>
|
||||
<translation>
|
||||
|
||||
无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Transaction Resume Failed</source>
|
||||
<translation>事务续办失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot read the Bootstrap update transaction: %1</source>
|
||||
<translation>无法读取 Bootstrap 更新事务:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Rolled Back</source>
|
||||
<translation>更新已回滚</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version could not be installed or started. The old version was restored and started automatically.</source>
|
||||
<translation>新版本安装或启动失败,已自动恢复并启动旧版本。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The old files were restored, but the old version number could not be written back. Check permissions on the system state store.</source>
|
||||
<translation>旧文件已经恢复,但旧版本号写回失败,请检查系统状态存储权限。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Automatic Rollback Failed</source>
|
||||
<translation>自动回滚失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bootstrap could not fully restore the old version. Do not continue running the software. Contact an administrator.</source>
|
||||
<translation>Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Preparation Failed</source>
|
||||
<translation>更新准备失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot create the update transaction directory or save its state.</source>
|
||||
<translation>无法创建更新事务目录或保存事务状态。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the offline transaction marker.</source>
|
||||
<translation>无法保存离线事务标记。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Fetching and validating the version manifest...</source>
|
||||
<translation>正在获取并验证版本清单...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the offline update package...</source>
|
||||
<translation>正在验证离线更新包...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Manifest Cache Failed</source>
|
||||
<translation>清单缓存失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed manifest cache could not be read after Bootstrap installation.</source>
|
||||
<translation>Bootstrap 安装后无法读取签名 Manifest 缓存。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Security Validation Failed</source>
|
||||
<translation>安全验证失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The version manifest signature could not be revalidated after Bootstrap installation.</source>
|
||||
<translation>Bootstrap 安装后无法重新验证版本清单签名。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The version manifest signature is invalid. The update has stopped. Contact an administrator.</source>
|
||||
<translation>版本清单签名无效,更新已停止。请联系管理员。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the new version manifest cache.</source>
|
||||
<translation>无法保存新版本 Manifest 缓存。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the new version manifest cache. The update has stopped.</source>
|
||||
<translation>无法保存新版本 Manifest 缓存,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Fetching secure download URLs...</source>
|
||||
<translation>正在获取安全下载地址...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Preparing offline package files...</source>
|
||||
<translation>正在准备离线包文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>No Update Files</source>
|
||||
<translation>没有可更新文件</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The server returned no version files. The update has stopped.</source>
|
||||
<translation>服务器没有返回任何版本文件,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Insufficient Disk Space</source>
|
||||
<translation>磁盘空间不足</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The update requires at least %1 of free space, but the installation drive has only %2 available.
|
||||
The required space includes downloaded files, the old-version backup, and a safety margin.</source>
|
||||
<translation>更新至少需要 %1 可用空间,安装盘当前仅剩 %2。
|
||||
所需空间已包含下载文件、旧版本备份和安全余量。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Downloading and validating %1 version files...</source>
|
||||
<translation>正在下载并校验 %1 个版本文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Extracting and validating %1 offline files...</source>
|
||||
<translation>正在提取并校验 %1 个离线文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline Package Extraction Failed</source>
|
||||
<translation>离线包提取失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Download Failed</source>
|
||||
<translation>下载失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Some files could not be downloaded or failed SHA-256 validation. Check the network and try again.</source>
|
||||
<translation>部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the complete version files...</source>
|
||||
<translation>正在校验完整版本文件...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>File Validation Failed</source>
|
||||
<translation>文件校验失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The staged files do not match the version manifest. The update has stopped.</source>
|
||||
<translation>暂存文件与版本清单不一致,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bootstrap Cannot Update Itself</source>
|
||||
<translation>Bootstrap 无法自更新</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>This version contains a new %1. Upgrade this component with the installer, then republish the application version.</source>
|
||||
<translation>本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Transaction Recording Failed</source>
|
||||
<translation>事务记录失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the validated-file or pending-deletion lists. The update has stopped.</source>
|
||||
<translation>无法保存已校验或待删除文件列表,更新已停止。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Closing the main application...</source>
|
||||
<translation>正在关闭主程序...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot Close Main Application</source>
|
||||
<translation>无法关闭主程序</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>%1 is still running. Close it manually and try again.</source>
|
||||
<translation>%1 仍在运行,请手动关闭后重试。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backing up %1 files to be changed, including %2 deletions...</source>
|
||||
<translation>正在备份 %1 个待变更文件(其中删除 %2 个)...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Backup Failed</source>
|
||||
<translation>备份失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot back up the current version files. The new version has not been installed. Check disk space and directory permissions.</source>
|
||||
<translation>无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Handoff Preparation Failed</source>
|
||||
<translation>接管准备失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot create the Bootstrap file plan.</source>
|
||||
<translation>无法创建 Bootstrap 文件计划。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot write the Bootstrap copy plan.</source>
|
||||
<translation>无法写入 Bootstrap 复制计划。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot write the Bootstrap deletion plan.</source>
|
||||
<translation>无法写入 Bootstrap 删除计划。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot commit the Bootstrap file plan or transaction state.</source>
|
||||
<translation>无法提交 Bootstrap 文件计划或事务状态。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Handing installation over to Bootstrap...</source>
|
||||
<translation>正在将安装工作移交给 Bootstrap...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Bootstrap Startup Failed</source>
|
||||
<translation>Bootstrap 启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot start the independent update handoff program: %1</source>
|
||||
<translation>无法启动独立更新接管程序:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Validating the Bootstrap installation result...</source>
|
||||
<translation>正在校验 Bootstrap 安装结果...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Installation Validation Failed</source>
|
||||
<translation>安装校验失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version files failed validation after installation.</source>
|
||||
<translation>新版本文件安装后校验未通过。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Obsolete File Cleanup Failed</source>
|
||||
<translation>废弃文件清理失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>An obsolete file still exists: %1</source>
|
||||
<translation>废弃文件仍然存在:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Saving the new version state...</source>
|
||||
<translation>正在保存新版本状态...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>State Save Failed</source>
|
||||
<translation>状态保存失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot save the current version number.</source>
|
||||
<translation>无法保存当前版本号。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Starting the new version and waiting for a health confirmation...</source>
|
||||
<translation>正在启动新版本并等待健康确认...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Failed</source>
|
||||
<translation>启动失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>%1 could not be started.</source>
|
||||
<translation>%1 无法启动。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Startup Confirmation Failed</source>
|
||||
<translation>启动确认失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version did not complete its startup health confirmation within %1 milliseconds.</source>
|
||||
<translation>新版本在 %1 毫秒内没有完成启动健康确认。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Committing the update transaction...</source>
|
||||
<translation>正在提交更新事务...</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Transaction Commit Failed</source>
|
||||
<translation>事务提交失败</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The new version started, but the update transaction could not be committed.</source>
|
||||
<translation>新版本已经启动,但无法提交更新事务。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Complete</source>
|
||||
<translation>更新完成</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The software was updated successfully to %1 and passed the startup health check.</source>
|
||||
<translation>软件已成功更新到 %1,并通过启动健康检查。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Update Denied</source>
|
||||
<translation>更新被拒绝</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The local signed policy is invalid, expired, not applicable to this installation, or does not allow the requested update.</source>
|
||||
<translation>本地签名策略无效、已过期、不适用于此安装,或不允许请求的更新。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The current signed policy does not allow installing an older version.</source>
|
||||
<translation>当前签名策略不允许安装旧版本。</translation>
|
||||
</message>
|
||||
</context>
|
||||
<context>
|
||||
<name>UpdaterLogic</name>
|
||||
<message>
|
||||
<source>Cannot open the offline update package.</source>
|
||||
<translation>无法打开离线更新包。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package format identifier is invalid.</source>
|
||||
<translation>离线包格式标识无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package header is incomplete.</source>
|
||||
<translation>离线包头不完整。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package header length is invalid.</source>
|
||||
<translation>离线包头长度无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package header contains invalid JSON.</source>
|
||||
<translation>离线包头 JSON 无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package RSA signature is invalid.</source>
|
||||
<translation>离线包 RSA 签名无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The signed offline package metadata is invalid.</source>
|
||||
<translation>离线包签名元数据无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The manifest digest does not match the package signature.</source>
|
||||
<translation>Manifest 摘要与包签名不一致。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline manifest is invalid.</source>
|
||||
<translation>离线 Manifest 无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The package metadata and manifest identity do not match.</source>
|
||||
<translation>包信息与 Manifest 身份不一致。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline manifest RSA signature is invalid.</source>
|
||||
<translation>离线 Manifest RSA 签名无效。</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The offline package contains an unsafe path or out-of-bounds data: %1</source>
|
||||
<translation>离线包包含不安全路径或越界数据:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot prepare the offline file: %1</source>
|
||||
<translation>无法准备离线文件:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Cannot create the staged file: %1</source>
|
||||
<translation>无法创建暂存文件:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Failed to read the offline file: %1</source>
|
||||
<translation>离线文件读取失败:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>Offline file hash validation or writing failed: %1</source>
|
||||
<translation>离线文件 Hash 校验或写入失败:%1</translation>
|
||||
</message>
|
||||
<message>
|
||||
<source>The number of files in the offline package does not match the manifest.</source>
|
||||
<translation>离线包文件数量与 Manifest 不一致。</translation>
|
||||
</message>
|
||||
</context>
|
||||
</TS>
|
||||
@@ -1,117 +0,0 @@
|
||||
# Hub 更新客户端打包成 SDK
|
||||
|
||||
本文只说明如何从 `SIMCAE/update-client` 生成给 SIMCAE 开发者使用的 SDK 包。SIMCAE 如何拿这个 SDK 打客户安装器和交付包,见项目根目录的 [客户端部署.md](../../客户端部署.md)。
|
||||
|
||||
## 一、SDK 包含什么
|
||||
|
||||
SDK 用来把 Hub 更新客户端接入 SIMCAE 安装包。
|
||||
|
||||
| 内容 | 作用 |
|
||||
| --- | --- |
|
||||
| `Launcher.exe` | 客户日常启动入口,检查整包更新并启动主程序 |
|
||||
| `Updater.exe` | 拉取 Manifest、下载发布包、校验 SHA-256、准备安装 |
|
||||
| `Bootstrap.exe` | 替换运行中文件时接管安装 |
|
||||
| Qt 运行库 | 可选,给没有单独 Qt 运行环境的接入方使用 |
|
||||
| 文档 | 说明客户端目录结构、配置字段和接入方式 |
|
||||
|
||||
SDK 不包含最终客户配置文件,例如 `app_config.json`、`server_config.json`、`server_config.qrc`、`manifest_public_key.pem`。这些文件由服务端在上传客户软件包或 Qt IFW 交付包时生成或注入。
|
||||
|
||||
## 二、编译 Release
|
||||
|
||||
先进入 SIMCAE 仓库下的 `update-client` 目录。如果当前已经在 SIMCAE 仓库根目录:
|
||||
|
||||
```powershell
|
||||
cd .\update-client
|
||||
```
|
||||
|
||||
然后执行:
|
||||
|
||||
```powershell
|
||||
cmake --preset x64-release
|
||||
cmake --build --preset x64-release
|
||||
```
|
||||
|
||||
编译完成后,Release 产物通常位于 `out/bin/Release`。
|
||||
|
||||
检查核心程序:
|
||||
|
||||
```powershell
|
||||
Test-Path .\out\bin\Release\Launcher.exe
|
||||
Test-Path .\out\bin\Release\Updater.exe
|
||||
Test-Path .\out\bin\Release\Bootstrap.exe
|
||||
```
|
||||
|
||||
预期都返回 `True`。
|
||||
|
||||
## 三、打包不带 Qt 运行库的 SDK
|
||||
|
||||
适用于接入方已经有 Qt 运行环境,或希望自己控制 Qt DLL 的情况。
|
||||
|
||||
```powershell
|
||||
.\scripts\package-sdk.ps1 `
|
||||
-SourceDir .\out\bin\Release `
|
||||
-OutputDir .\dist\SimCAEHubUpdateClientSDK `
|
||||
-ZipFile .\dist\SimCAEHubUpdateClientSDK.zip `
|
||||
-SdkVersion 0.1.0
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
| 输出 | 说明 |
|
||||
| --- | --- |
|
||||
| `dist\SimCAEHubUpdateClientSDK` | SDK 展开目录 |
|
||||
| `dist\SimCAEHubUpdateClientSDK.zip` | 可交给 SIMCAE 开发者的 SDK 压缩包 |
|
||||
|
||||
## 四、打包带 Qt 运行库的 SDK
|
||||
|
||||
适用于接入方不想单独准备 Qt DLL,或者希望拿到后能直接放进安装包。
|
||||
|
||||
```powershell
|
||||
.\scripts\package-sdk.ps1 `
|
||||
-SourceDir .\out\bin\Release `
|
||||
-OutputDir .\dist\SimCAEHubUpdateClientSDK-with-qt `
|
||||
-ZipFile .\dist\SimCAEHubUpdateClientSDK-with-qt.zip `
|
||||
-SdkVersion 0.1.0 `
|
||||
-IncludeQtRuntime
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
| 输出 | 说明 |
|
||||
| --- | --- |
|
||||
| `dist\SimCAEHubUpdateClientSDK-with-qt` | 带 Qt 运行库的 SDK 展开目录 |
|
||||
| `dist\SimCAEHubUpdateClientSDK-with-qt.zip` | 推荐交给 SIMCAE 开发者的 SDK 压缩包 |
|
||||
|
||||
## 五、打包后检查
|
||||
|
||||
```powershell
|
||||
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Launcher.exe
|
||||
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Updater.exe
|
||||
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt\bin\Bootstrap.exe
|
||||
Test-Path .\dist\SimCAEHubUpdateClientSDK-with-qt.zip
|
||||
```
|
||||
|
||||
预期都返回 `True`。
|
||||
|
||||
## 六、不要提交的内容
|
||||
|
||||
`SIMCAE/update-client/.gitignore` 已忽略这些本地内容:
|
||||
|
||||
- `thirdparty/`
|
||||
- `out/`
|
||||
- `dist/`
|
||||
- `*.exe`
|
||||
- `*.dll`
|
||||
- `*.zip`
|
||||
- `config/app_config.json`
|
||||
- `config/client_identity.dat`
|
||||
- `config/local_state.json`
|
||||
- `config/version_policy.dat`
|
||||
|
||||
提交前看一下:
|
||||
|
||||
```powershell
|
||||
git status --short
|
||||
```
|
||||
|
||||
不要把本地依赖、编译产物、SDK ZIP、客户配置和运行状态提交进仓库。
|
||||
Reference in New Issue
Block a user