Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 613607475e | |||
| 8dfef45bc4 | |||
| 0c500024e6 | |||
| d9e32c6cea | |||
| 1c8ec37d2b | |||
| fcd67e08aa | |||
| fb6b080ad4 | |||
| 4ddc5dfdf5 | |||
| d05597503c | |||
| f703ab0302 | |||
| c960d3b04d | |||
| 94b20bf2bb | |||
| cb5819ab7c |
@@ -0,0 +1,6 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8-bom
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
@@ -22,11 +22,14 @@ Desktop.ini
|
|||||||
*.pdf
|
*.pdf
|
||||||
*.doc
|
*.doc
|
||||||
*.docx
|
*.docx
|
||||||
|
private/
|
||||||
|
pdf_requirements.txt
|
||||||
|
|
||||||
# C/C++ generated artifacts
|
# C/C++ generated artifacts
|
||||||
*.obj
|
*.obj
|
||||||
*.o
|
*.o
|
||||||
*.pdb
|
*.pdb
|
||||||
|
*.qm
|
||||||
*.ilk
|
*.ilk
|
||||||
*.idb
|
*.idb
|
||||||
*.tlog
|
*.tlog
|
||||||
@@ -49,6 +52,7 @@ CMakeUserPresets.json
|
|||||||
Testing/
|
Testing/
|
||||||
|
|
||||||
# Third-party/business binary drops and generated packages
|
# Third-party/business binary drops and generated packages
|
||||||
|
thirdparty/
|
||||||
App/
|
App/
|
||||||
dist/
|
dist/
|
||||||
*.zip
|
*.zip
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
project(Bootstrap LANGUAGES CXX)
|
project(Bootstrap LANGUAGES CXX)
|
||||||
|
|
||||||
add_executable(Bootstrap WIN32 main.cpp)
|
add_executable(Bootstrap
|
||||||
|
main.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||||
|
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||||
|
)
|
||||||
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
target_compile_features(Bootstrap PRIVATE cxx_std_17)
|
||||||
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
|
target_link_libraries(Bootstrap PRIVATE Qt5::Core Qt5::Widgets)
|
||||||
target_link_libraries(Bootstrap PRIVATE shell32)
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|||||||
+151
-138
@@ -1,190 +1,203 @@
|
|||||||
#include <windows.h>
|
#include <QApplication>
|
||||||
#include <shellapi.h>
|
#include <QCoreApplication>
|
||||||
#include <filesystem>
|
#include <QDir>
|
||||||
#include <chrono>
|
#include <QFile>
|
||||||
#include <cstdlib>
|
#include <QFileInfo>
|
||||||
#include <fstream>
|
#include <QMessageBox>
|
||||||
#include <string>
|
#include <QProcess>
|
||||||
#include <thread>
|
#include <QTextStream>
|
||||||
#include <vector>
|
#include <QThread>
|
||||||
|
#include <QTranslator>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
namespace fs = std::filesystem;
|
struct PlanItem
|
||||||
|
|
||||||
static std::wstring quote(const std::wstring& value)
|
|
||||||
{
|
{
|
||||||
std::wstring result = L"\"";
|
QChar operation;
|
||||||
unsigned backslashes = 0;
|
QString relativePath;
|
||||||
for (wchar_t ch : value) {
|
};
|
||||||
if (ch == L'\\') { ++backslashes; continue; }
|
|
||||||
if (ch == L'\"') {
|
static void showError(const QString& message)
|
||||||
result.append(backslashes * 2 + 1, L'\\');
|
{
|
||||||
result.push_back(L'\"');
|
QMessageBox::critical(nullptr,
|
||||||
backslashes = 0;
|
QApplication::translate("Bootstrap", "Update Handoff Failed"),
|
||||||
continue;
|
message);
|
||||||
}
|
|
||||||
result.append(backslashes, L'\\');
|
|
||||||
backslashes = 0;
|
|
||||||
result.push_back(ch);
|
|
||||||
}
|
|
||||||
result.append(backslashes * 2, L'\\');
|
|
||||||
result.push_back(L'\"');
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static std::wstring fromUtf8(const std::string& value)
|
static QString cleanRelativePath(const QString& value)
|
||||||
{
|
{
|
||||||
if (value.empty()) return {};
|
QString path = QDir::fromNativeSeparators(value.trimmed());
|
||||||
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
if (path.isEmpty())
|
||||||
static_cast<int>(value.size()), nullptr, 0);
|
return {};
|
||||||
if (size <= 0) return {};
|
|
||||||
std::wstring result(size, L'\0');
|
path = QDir::cleanPath(path);
|
||||||
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
|
if (path == "." || path == ".." || path.startsWith("../")
|
||||||
static_cast<int>(value.size()), result.data(), size);
|
|| path.contains("/../") || QDir::isAbsolutePath(path)
|
||||||
return result;
|
|| path.contains(':')) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool safeRelative(const fs::path& path)
|
static QString joinPath(const QString& root, const QString& relativePath)
|
||||||
{
|
{
|
||||||
if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
|
return QDir(root).filePath(relativePath);
|
||||||
for (const auto& part : path)
|
}
|
||||||
if (part == L"..") return false;
|
|
||||||
|
static bool removeWithRetry(const QString& path)
|
||||||
|
{
|
||||||
|
// Windows 上主程序退出后,DLL/EXE 句柄可能还会短时间被系统占用。
|
||||||
|
// Bootstrap 用短重试等待文件释放,而不是一次失败就判定升级失败。
|
||||||
|
for (int i = 0; i < 100; ++i) {
|
||||||
|
if (!QFileInfo::exists(path))
|
||||||
return true;
|
return true;
|
||||||
|
if (QFile::remove(path))
|
||||||
|
return true;
|
||||||
|
QThread::msleep(100);
|
||||||
|
}
|
||||||
|
return !QFileInfo::exists(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool copyWithRetry(const fs::path& source, const fs::path& destination)
|
static bool copyWithRetry(const QString& source, const QString& destination)
|
||||||
{
|
{
|
||||||
std::error_code ec;
|
QDir().mkpath(QFileInfo(destination).absolutePath());
|
||||||
fs::create_directories(destination.parent_path(), ec);
|
|
||||||
for (int i = 0; i < 100; ++i) {
|
for (int i = 0; i < 100; ++i) {
|
||||||
ec.clear();
|
QFile::remove(destination);
|
||||||
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec);
|
if (QFile::copy(source, destination))
|
||||||
if (!ec) return true;
|
return true;
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
QThread::msleep(100);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool removeWithRetry(const fs::path& path)
|
static bool rollback(const QString& installDir, const QString& backupDir,
|
||||||
{
|
const QVector<QString>& paths)
|
||||||
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;
|
bool success = true;
|
||||||
for (const auto& relative : paths) {
|
for (const QString& relativePath : paths) {
|
||||||
const fs::path destination = installDir / relative;
|
const QString destination = joinPath(installDir, relativePath);
|
||||||
const fs::path backup = backupDir / relative;
|
const QString backup = joinPath(backupDir, relativePath);
|
||||||
std::error_code ec;
|
if (QFileInfo::exists(backup)) {
|
||||||
if (fs::exists(backup, ec)) {
|
if (!copyWithRetry(backup, destination))
|
||||||
if (!copyWithRetry(backup, destination)) success = false;
|
success = false;
|
||||||
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
|
} else if (QFileInfo::exists(destination) && !removeWithRetry(destination)) {
|
||||||
success = false;
|
success = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool launchUpdater(const std::wstring& updater, const std::vector<std::wstring>& updateArgs,
|
static bool launchUpdater(const QString& updater, const QStringList& updateArgs,
|
||||||
const std::wstring& result)
|
const QString& result)
|
||||||
{
|
{
|
||||||
std::wstring command = quote(updater);
|
QStringList args = updateArgs;
|
||||||
for (const auto& arg : updateArgs) command += L" " + quote(arg);
|
args.append(QStringLiteral("--bootstrap-resume=%1").arg(result));
|
||||||
command += L" " + quote(L"--bootstrap-resume=" + result);
|
return QProcess::startDetached(updater, args, QFileInfo(updater).absolutePath());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
|
static bool readPlan(const QString& planFile, QVector<PlanItem>* items, QVector<QString>* paths)
|
||||||
{
|
{
|
||||||
int argc = 0;
|
QFile file(planFile);
|
||||||
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
|
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
if (!argv || argc < 11) {
|
return false;
|
||||||
MessageBoxW(nullptr, L"Bootstrap 参数不完整。", L"更新接管失败", MB_ICONERROR);
|
|
||||||
if (argv) LocalFree(argv);
|
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."));
|
||||||
return 2;
|
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);
|
|
||||||
|
|
||||||
if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
|
const QString planFile = arguments.at(1);
|
||||||
WaitForSingleObject(process, 30000);
|
const QString installDir = arguments.at(2);
|
||||||
CloseHandle(process);
|
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");
|
||||||
|
|
||||||
struct PlanItem { wchar_t operation; fs::path relative; };
|
QThread::msleep(500);
|
||||||
std::ifstream input(planFile, std::ios::binary);
|
|
||||||
std::vector<PlanItem> items;
|
QVector<PlanItem> items;
|
||||||
std::vector<fs::path> paths;
|
QVector<QString> paths;
|
||||||
std::string line;
|
if (!readPlan(planFile, &items, &paths)) {
|
||||||
while (std::getline(input, line)) {
|
showError(QApplication::translate("Bootstrap", "The update plan is missing or invalid."));
|
||||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
|
||||||
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
|
|
||||||
MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR);
|
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
const fs::path relative(fromUtf8(line.substr(2)));
|
|
||||||
if (!safeRelative(relative)) {
|
|
||||||
MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR);
|
|
||||||
return 3;
|
|
||||||
}
|
|
||||||
items.push_back({static_cast<wchar_t>(line[0]), relative});
|
|
||||||
paths.push_back(relative);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool success = input.eof();
|
bool success = true;
|
||||||
bool rolledBack = false;
|
bool rolledBack = false;
|
||||||
fs::path failedPath;
|
QString failedPath;
|
||||||
if (mode == L"rollback") {
|
if (mode == QStringLiteral("rollback")) {
|
||||||
rolledBack = success && rollback(installDir, backupDir, paths);
|
rolledBack = rollback(installDir, backupDir, paths);
|
||||||
success = false;
|
success = false;
|
||||||
} else if (success) {
|
} else {
|
||||||
for (const auto& item : items) {
|
// Bootstrap 是替换文件的接力进程:Updater 先退出,Bootstrap 再覆盖安装目录。
|
||||||
const fs::path& relative = item.relative;
|
// 它不会更新自身,避免正在运行的 Bootstrap 被覆盖导致升级中断。
|
||||||
if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) {
|
for (const PlanItem& item : items) {
|
||||||
|
const QString& relativePath = item.relativePath;
|
||||||
|
if (isBootstrapSelfPath(relativePath)) {
|
||||||
success = false;
|
success = false;
|
||||||
failedPath = relative;
|
failedPath = relativePath;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const bool itemOk = item.operation == L'C'
|
|
||||||
? copyWithRetry(stagingDir / relative, installDir / relative)
|
const bool itemOk = item.operation == QLatin1Char('C')
|
||||||
: removeWithRetry(installDir / relative);
|
? copyWithRetry(joinPath(stagingDir, relativePath), joinPath(installDir, relativePath))
|
||||||
|
: removeWithRetry(joinPath(installDir, relativePath));
|
||||||
if (!itemOk) {
|
if (!itemOk) {
|
||||||
success = false;
|
success = false;
|
||||||
failedPath = relative;
|
failedPath = relativePath;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!success) rolledBack = rollback(installDir, backupDir, paths);
|
if (!success)
|
||||||
|
rolledBack = rollback(installDir, backupDir, paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
|
const QString result = success ? QStringLiteral("success")
|
||||||
|
: (rolledBack ? QStringLiteral("rolledback") : QStringLiteral("rollback-failed"));
|
||||||
if (!launchUpdater(updater, updateArgs, result)) {
|
if (!launchUpdater(updater, updateArgs, result)) {
|
||||||
std::wstring message = L"无法重新启动 Updater.exe。";
|
QString message = QApplication::translate("Bootstrap", "Cannot restart Updater.");
|
||||||
if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring();
|
if (!failedPath.isEmpty()) {
|
||||||
MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR);
|
message += QApplication::translate("Bootstrap", "\nFailed file: %1")
|
||||||
|
.arg(failedPath);
|
||||||
|
}
|
||||||
|
showError(message);
|
||||||
return 4;
|
return 4;
|
||||||
}
|
}
|
||||||
return (success || rolledBack) ? 0 : 5;
|
return (success || rolledBack) ? 0 : 5;
|
||||||
|
|||||||
+111
-24
@@ -1,55 +1,139 @@
|
|||||||
cmake_minimum_required(VERSION 3.20)
|
cmake_minimum_required(VERSION 3.20)
|
||||||
project(ClientAll LANGUAGES C CXX)
|
project(ClientAll LANGUAGES C CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|
||||||
if(MSVC)
|
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)
|
add_compile_options(/utf-8)
|
||||||
endif()
|
endif()
|
||||||
# Qt全局配置
|
|
||||||
|
# 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_INCLUDE_CURRENT_DIR ON)
|
set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
set(CMAKE_AUTOUIC ON)
|
set(CMAKE_AUTOUIC ON)
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
|
|
||||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
||||||
# ========== OpenSSL 手动配置(完全抛弃find_package) ==========
|
|
||||||
set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64")
|
get_target_property(QT_QMAKE_EXECUTABLE Qt5::qmake IMPORTED_LOCATION)
|
||||||
set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include")
|
get_filename_component(QT_BIN_DIR "${QT_QMAKE_EXECUTABLE}" DIRECTORY)
|
||||||
set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd")
|
find_program(QT_LRELEASE_EXECUTABLE NAMES lrelease lrelease.exe HINTS "${QT_BIN_DIR}" REQUIRED)
|
||||||
set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD")
|
find_program(QT_LUPDATE_EXECUTABLE NAMES lupdate lupdate.exe HINTS "${QT_BIN_DIR}" REQUIRED)
|
||||||
# 全局宏,所有子项目统一启用OpenSSL
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
add_custom_target(update_client_translations ALL
|
||||||
|
DEPENDS "${TRANSLATION_QM}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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>."
|
||||||
|
)
|
||||||
|
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)
|
add_compile_definitions(HAVE_OPENSSL=1)
|
||||||
# ==========================================================
|
|
||||||
# 统一输出目录
|
# 统一输出目录。Visual Studio Release 实际输出到 out/bin/Release;Linux 单独输出到 out/linux/bin。
|
||||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
|
if(UNIX AND NOT APPLE)
|
||||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
set(SIMCAE_OUTPUT_ROOT ${CMAKE_SOURCE_DIR}/out/linux)
|
||||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
else()
|
||||||
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库
|
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(Bootstrap)
|
||||||
|
|
||||||
# 先编译公共库
|
# 先编译公共库
|
||||||
add_subdirectory(Common)
|
add_subdirectory(Common)
|
||||||
|
|
||||||
# 再编译三个业务程序
|
# 再编译三个业务程序
|
||||||
add_subdirectory(Launcher)
|
add_subdirectory(Launcher)
|
||||||
add_subdirectory(Updater)
|
add_subdirectory(Updater)
|
||||||
add_subdirectory(MainApp)
|
add_subdirectory(MainApp)
|
||||||
|
|
||||||
# 默认启动项目
|
# 默认启动项目
|
||||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||||
# Copy client.ini and config directory to output bin folder
|
|
||||||
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
|
# 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(CONFIG_SOURCE_DIR "${CMAKE_SOURCE_DIR}/config")
|
||||||
set(MANIFEST_PUBLIC_KEY_FILE "${CONFIG_SOURCE_DIR}/manifest_public_key.pem")
|
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}")
|
set(INI_TARGET_FOLDER "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}")
|
||||||
|
|
||||||
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
|
# app_config.json 包含运行时版本状态;如果存在旧 client.ini,则交给客户端首次启动迁移
|
||||||
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
|
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}")
|
||||||
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
|
file(MAKE_DIRECTORY "${INI_TARGET_FOLDER}/config")
|
||||||
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/app_config.json" AND NOT EXISTS "${INI_TARGET_FOLDER}/client.ini")
|
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("${APP_CONFIG_SOURCE_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
|
configure_file("${LOCAL_APP_CONFIG_FILE}" "${INI_TARGET_FOLDER}/config/app_config.json" COPYONLY)
|
||||||
endif()
|
endif()
|
||||||
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
|
foreach(RUNTIME_RESOURCE local_state.json version_policy.dat)
|
||||||
if(NOT EXISTS "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}")
|
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)
|
configure_file("${CONFIG_SOURCE_DIR}/${RUNTIME_RESOURCE}" "${INI_TARGET_FOLDER}/config/${RUNTIME_RESOURCE}" COPYONLY)
|
||||||
endif()
|
endif()
|
||||||
endforeach()
|
endforeach()
|
||||||
|
|
||||||
# 编译阶段同步静态配置资源
|
# 编译阶段同步静态配置资源
|
||||||
add_custom_target(copy_client_resources ALL
|
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}
|
||||||
@@ -61,10 +145,13 @@ add_custom_target(copy_client_resources ALL
|
|||||||
add_dependencies(Launcher copy_client_resources)
|
add_dependencies(Launcher copy_client_resources)
|
||||||
add_dependencies(Updater copy_client_resources)
|
add_dependencies(Updater copy_client_resources)
|
||||||
add_dependencies(MainApp 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)
|
||||||
|
|
||||||
# Install rules for packaging
|
# Install rules for packaging
|
||||||
if(EXISTS ${CONFIG_SOURCE_DIR})
|
if(EXISTS "${MANIFEST_PUBLIC_KEY_FILE}")
|
||||||
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config)
|
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin/config)
|
||||||
endif()
|
install(FILES "${MANIFEST_PUBLIC_KEY_FILE}" DESTINATION bin)
|
||||||
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE})
|
|
||||||
install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin)
|
|
||||||
endif()
|
endif()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
project(Common LANGUAGES C CXX)
|
project(Common LANGUAGES C CXX)
|
||||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
|
|
||||||
|
|
||||||
set(SRC
|
set(SRC
|
||||||
HttpHelper.h
|
HttpHelper.h
|
||||||
@@ -14,10 +13,10 @@ set(SRC
|
|||||||
LocalStateHelper.cpp
|
LocalStateHelper.cpp
|
||||||
TicketHelper.h
|
TicketHelper.h
|
||||||
TicketHelper.cpp
|
TicketHelper.cpp
|
||||||
|
UpdatePathPolicy.h
|
||||||
|
UpdatePathPolicy.cpp
|
||||||
IntegrityHelper.h
|
IntegrityHelper.h
|
||||||
IntegrityHelper.cpp
|
IntegrityHelper.cpp
|
||||||
DeviceIdentityHelper.h
|
|
||||||
DeviceIdentityHelper.cpp
|
|
||||||
)
|
)
|
||||||
add_library(Common STATIC ${SRC})
|
add_library(Common STATIC ${SRC})
|
||||||
|
|
||||||
@@ -27,12 +26,11 @@ target_include_directories(Common
|
|||||||
PRIVATE ${OPENSSL_INC}
|
PRIVATE ${OPENSSL_INC}
|
||||||
)
|
)
|
||||||
|
|
||||||
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
|
|
||||||
target_link_directories(Common INTERFACE
|
|
||||||
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
|
|
||||||
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
|
|
||||||
)
|
|
||||||
target_link_libraries(Common
|
target_link_libraries(Common
|
||||||
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
|
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
|
||||||
INTERFACE libssl.lib libcrypto.lib
|
PUBLIC ${SIMCAE_OPENSSL_LINK_LIBRARIES}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(Common INTERFACE shell32)
|
||||||
|
endif()
|
||||||
|
|||||||
+747
-60
@@ -1,13 +1,328 @@
|
|||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QStringList>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <string>
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
#ifndef NOMINMAX
|
||||||
|
#define NOMINMAX
|
||||||
|
#endif
|
||||||
|
#include <windows.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
const QString kElevatedConfigSetFlag = QStringLiteral("--simcae-config-set");
|
||||||
|
const QString kElevatedFileWriteFlag = QStringLiteral("--simcae-file-write");
|
||||||
|
const QString kElevatedFileRemoveFlag = QStringLiteral("--simcae-file-remove");
|
||||||
|
const QString kConfigPathPrefix = QStringLiteral("--config-path-b64=");
|
||||||
|
const QString kConfigKeyPrefix = QStringLiteral("--config-key-b64=");
|
||||||
|
const QString kConfigValuePrefix = QStringLiteral("--config-value-b64=");
|
||||||
|
const QString kFilePathPrefix = QStringLiteral("--file-path-b64=");
|
||||||
|
const QString kFileDataPrefix = QStringLiteral("--file-data-b64=");
|
||||||
|
const QString kRegistryOrganization = QStringLiteral("SimCAE");
|
||||||
|
const QString kRegistryApplication = QStringLiteral("HubUpdateClient");
|
||||||
|
const QString kRegistryInstallationsGroup = QStringLiteral("installations");
|
||||||
|
const QString kRegistryConfigGroup = QStringLiteral("config");
|
||||||
|
const QString kRegistryMetaGroup = QStringLiteral("_meta");
|
||||||
|
const QString kRegistrySourceHashKey = QStringLiteral("source_sha256");
|
||||||
|
const QString kRegistrySourcePathKey = QStringLiteral("source_path");
|
||||||
|
const QString kRegistryRuntimeRootKey = QStringLiteral("runtime_root");
|
||||||
|
const QString kRegistryImportedAtKey = QStringLiteral("imported_at_utc");
|
||||||
|
const QString kEmbeddedServerConfigPath = QStringLiteral(":/simcae/server_config.json");
|
||||||
|
const QString kApiBaseUrlKey = QStringLiteral("api_base_url");
|
||||||
|
|
||||||
|
// 需要提权写入时,子进程参数统一用 Base64Url 编码。
|
||||||
|
// 这样可以避免 Windows 路径、中文、空格或换行在 ShellExecute 参数传递中被截断或误解析。
|
||||||
|
QString encodeArgument(const QString& value)
|
||||||
|
{
|
||||||
|
return QString::fromLatin1(value.toUtf8().toBase64(
|
||||||
|
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString encodeBytes(const QByteArray& value)
|
||||||
|
{
|
||||||
|
return QString::fromLatin1(value.toBase64(
|
||||||
|
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString decodeArgument(const QString& value)
|
||||||
|
{
|
||||||
|
return QString::fromUtf8(QByteArray::fromBase64(value.toLatin1(),
|
||||||
|
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals));
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray decodeBytes(const QString& value)
|
||||||
|
{
|
||||||
|
return QByteArray::fromBase64(value.toLatin1(),
|
||||||
|
QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString findArgumentValue(const QStringList& arguments, const QString& prefix)
|
||||||
|
{
|
||||||
|
for (const QString& argument : arguments)
|
||||||
|
if (argument.startsWith(prefix))
|
||||||
|
return argument.mid(prefix.size());
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeBytesToFile(const QString& path, const QByteArray& bytes, QString* errorMessage)
|
||||||
|
{
|
||||||
|
QDir dir(QFileInfo(path).path());
|
||||||
|
if (!dir.exists() && !dir.mkpath("."))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot create directory: %1").arg(dir.path());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSaveFile output(path);
|
||||||
|
if (!output.open(QIODevice::WriteOnly))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot open file for writing: %1").arg(output.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (output.write(bytes) != bytes.size())
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot write full file: %1").arg(output.errorString());
|
||||||
|
output.cancelWriting();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!output.commit())
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot commit file: %1").arg(output.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (errorMessage)
|
||||||
|
errorMessage->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool removeFile(const QString& path, QString* errorMessage)
|
||||||
|
{
|
||||||
|
if (!QFile::exists(path))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
errorMessage->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (QFile::remove(path))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
errorMessage->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot remove file: %1").arg(path);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeConfigValueToFile(const QString& configPath, const QString& key,
|
||||||
|
const QString& value, QString* errorMessage)
|
||||||
|
{
|
||||||
|
QFile input(configPath);
|
||||||
|
QJsonObject config;
|
||||||
|
if (input.exists())
|
||||||
|
{
|
||||||
|
if (!input.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot open config for reading: %1").arg(input.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QJsonParseError parseError;
|
||||||
|
const QByteArray raw = input.readAll();
|
||||||
|
input.close();
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||||
|
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Invalid config JSON: %1").arg(parseError.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
config = document.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
config.insert(key, value);
|
||||||
|
QDir dir(QFileInfo(configPath).path());
|
||||||
|
if (!dir.exists() && !dir.mkpath("."))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("Cannot create config directory: %1").arg(dir.path());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
||||||
|
return writeBytesToFile(configPath, payload, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isRegistryManagedConfigKey(const QString& key)
|
||||||
|
{
|
||||||
|
// 服务端地址是编译期 qrc 配置,不进入注册表。
|
||||||
|
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
|
||||||
|
Q_UNUSED(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPathInsideDirectory(const QString& path, const QString& directory)
|
||||||
|
{
|
||||||
|
if (path.isEmpty() || directory.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
QString normalizedPath = QDir::cleanPath(QFileInfo(path).absoluteFilePath());
|
||||||
|
QString normalizedDirectory = QDir::cleanPath(QFileInfo(directory).absoluteFilePath());
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
normalizedPath = normalizedPath.toLower();
|
||||||
|
normalizedDirectory = normalizedDirectory.toLower();
|
||||||
|
#endif
|
||||||
|
return normalizedPath == normalizedDirectory
|
||||||
|
|| normalizedPath.startsWith(normalizedDirectory + QDir::separator());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isUserDataPath(const QString& path)
|
||||||
|
{
|
||||||
|
return isPathInsideDirectory(path, ConfigHelper::instance().dataRoot());
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject registryManagedConfigObject(const QJsonObject& source)
|
||||||
|
{
|
||||||
|
QJsonObject result;
|
||||||
|
for (auto it = source.constBegin(); it != source.constEnd(); ++it)
|
||||||
|
{
|
||||||
|
if (isRegistryManagedConfigKey(it.key()))
|
||||||
|
result.insert(it.key(), it.value());
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
QString windowsErrorMessage(DWORD errorCode)
|
||||||
|
{
|
||||||
|
if (errorCode == ERROR_CANCELLED)
|
||||||
|
return QCoreApplication::translate("ConfigHelper", "The user canceled the administrator permission confirmation.");
|
||||||
|
return QCoreApplication::translate("ConfigHelper", "Windows error %1").arg(errorCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool runElevatedSelfCommand(const QStringList& arguments, const QString& targetPath,
|
||||||
|
const QString& originalError, QString* errorMessage)
|
||||||
|
{
|
||||||
|
// 安装到 C:\Program Files 等目录时,普通用户不能直接修改配置或运行态文件。
|
||||||
|
// 这里不让主进程一直以管理员运行,而是在确实需要写入时临时拉起自身完成单次写入。
|
||||||
|
const QMessageBox::StandardButton choice = QMessageBox::question(
|
||||||
|
nullptr,
|
||||||
|
QCoreApplication::translate("ConfigHelper", "Administrator Permission Required"),
|
||||||
|
QCoreApplication::translate("ConfigHelper", "The current operation needs administrator permission to modify a protected file.\n\nTarget file: %1\nReason: %2\n\nClick OK, then choose Yes in the Windows permission confirmation dialog.")
|
||||||
|
.arg(QDir::toNativeSeparators(targetPath), originalError),
|
||||||
|
QMessageBox::Ok | QMessageBox::Cancel,
|
||||||
|
QMessageBox::Ok);
|
||||||
|
if (choice != QMessageBox::Ok)
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QCoreApplication::translate("ConfigHelper", "The user canceled the administrator permission request.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString executable = QCoreApplication::applicationFilePath();
|
||||||
|
if (executable.isEmpty() || !QFileInfo::exists(executable))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QStringLiteral("Cannot locate current executable for elevated config write.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString parameters = arguments.join(QLatin1Char(' '));
|
||||||
|
const QString workingDir = QFileInfo(executable).absolutePath();
|
||||||
|
|
||||||
|
std::wstring verb = L"runas";
|
||||||
|
std::wstring file = executable.toStdWString();
|
||||||
|
std::wstring params = parameters.toStdWString();
|
||||||
|
std::wstring directory = workingDir.toStdWString();
|
||||||
|
|
||||||
|
SHELLEXECUTEINFOW info{};
|
||||||
|
info.cbSize = sizeof(info);
|
||||||
|
info.fMask = SEE_MASK_NOCLOSEPROCESS;
|
||||||
|
info.lpVerb = verb.c_str();
|
||||||
|
info.lpFile = file.c_str();
|
||||||
|
info.lpParameters = params.c_str();
|
||||||
|
info.lpDirectory = directory.c_str();
|
||||||
|
info.nShow = SW_HIDE;
|
||||||
|
|
||||||
|
if (!ShellExecuteExW(&info))
|
||||||
|
{
|
||||||
|
const DWORD err = GetLastError();
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QStringLiteral("Cannot request administrator permission: %1").arg(windowsErrorMessage(err));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
WaitForSingleObject(info.hProcess, INFINITE);
|
||||||
|
DWORD exitCode = 1;
|
||||||
|
GetExitCodeProcess(info.hProcess, &exitCode);
|
||||||
|
CloseHandle(info.hProcess);
|
||||||
|
if (exitCode != 0)
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QStringLiteral("Elevated config write failed with exit code %1.").arg(exitCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorMessage)
|
||||||
|
errorMessage->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeConfigValueWithElevation(const QString& configPath, const QString& key,
|
||||||
|
const QString& value, const QString& originalError,
|
||||||
|
QString* errorMessage)
|
||||||
|
{
|
||||||
|
const QStringList arguments{
|
||||||
|
kElevatedConfigSetFlag,
|
||||||
|
kConfigPathPrefix + encodeArgument(configPath),
|
||||||
|
kConfigKeyPrefix + encodeArgument(key),
|
||||||
|
kConfigValuePrefix + encodeArgument(value)
|
||||||
|
};
|
||||||
|
return runElevatedSelfCommand(arguments, configPath, originalError, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool writeBytesWithElevation(const QString& path, const QByteArray& bytes,
|
||||||
|
const QString& originalError, QString* errorMessage)
|
||||||
|
{
|
||||||
|
const QStringList arguments{
|
||||||
|
kElevatedFileWriteFlag,
|
||||||
|
kFilePathPrefix + encodeArgument(path),
|
||||||
|
kFileDataPrefix + encodeBytes(bytes)
|
||||||
|
};
|
||||||
|
return runElevatedSelfCommand(arguments, path, originalError, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool removeFileWithElevation(const QString& path, const QString& originalError, QString* errorMessage)
|
||||||
|
{
|
||||||
|
const QStringList arguments{
|
||||||
|
kElevatedFileRemoveFlag,
|
||||||
|
kFilePathPrefix + encodeArgument(path)
|
||||||
|
};
|
||||||
|
return runElevatedSelfCommand(arguments, path, originalError, errorMessage);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
ConfigHelper& ConfigHelper::instance()
|
ConfigHelper& ConfigHelper::instance()
|
||||||
{
|
{
|
||||||
@@ -15,12 +330,158 @@ ConfigHelper& ConfigHelper::instance()
|
|||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::executableNameForCurrentPlatform(const QString& configuredValue,
|
||||||
|
const QString& fallbackBaseName)
|
||||||
|
{
|
||||||
|
QString name = configuredValue.trimmed();
|
||||||
|
if (name.isEmpty())
|
||||||
|
name = fallbackBaseName.trimmed();
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
const QString fileName = QFileInfo(name).fileName();
|
||||||
|
if (!fileName.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive)
|
||||||
|
&& QFileInfo(fileName).suffix().isEmpty()) {
|
||||||
|
name += QStringLiteral(".exe");
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if (name.endsWith(QStringLiteral(".exe"), Qt::CaseInsensitive))
|
||||||
|
name.chop(4);
|
||||||
|
#endif
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ConfigHelper::runElevatedWriteCommandIfRequested()
|
||||||
|
{
|
||||||
|
const QStringList arguments = QCoreApplication::arguments();
|
||||||
|
if (arguments.contains(kElevatedConfigSetFlag))
|
||||||
|
{
|
||||||
|
const QString configPath = decodeArgument(findArgumentValue(arguments, kConfigPathPrefix));
|
||||||
|
const QString key = decodeArgument(findArgumentValue(arguments, kConfigKeyPrefix));
|
||||||
|
const QString value = decodeArgument(findArgumentValue(arguments, kConfigValuePrefix));
|
||||||
|
if (configPath.isEmpty() || key.isEmpty())
|
||||||
|
{
|
||||||
|
qWarning() << "Elevated config write arguments are incomplete.";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString errorMessage;
|
||||||
|
if (!writeConfigValueToFile(configPath, key, value, &errorMessage))
|
||||||
|
{
|
||||||
|
qWarning() << "Elevated config write failed:" << errorMessage;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.contains(kElevatedFileWriteFlag))
|
||||||
|
{
|
||||||
|
const QString path = decodeArgument(findArgumentValue(arguments, kFilePathPrefix));
|
||||||
|
const QByteArray bytes = decodeBytes(findArgumentValue(arguments, kFileDataPrefix));
|
||||||
|
if (path.isEmpty())
|
||||||
|
{
|
||||||
|
qWarning() << "Elevated file write arguments are incomplete.";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString errorMessage;
|
||||||
|
if (!writeBytesToFile(path, bytes, &errorMessage))
|
||||||
|
{
|
||||||
|
qWarning() << "Elevated file write failed:" << errorMessage;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arguments.contains(kElevatedFileRemoveFlag))
|
||||||
|
{
|
||||||
|
const QString path = decodeArgument(findArgumentValue(arguments, kFilePathPrefix));
|
||||||
|
if (path.isEmpty())
|
||||||
|
{
|
||||||
|
qWarning() << "Elevated file remove arguments are incomplete.";
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString errorMessage;
|
||||||
|
if (!removeFile(path, &errorMessage))
|
||||||
|
{
|
||||||
|
qWarning() << "Elevated file remove failed:" << errorMessage;
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::writeFileWithElevationIfNeeded(const QString& path, const QByteArray& data,
|
||||||
|
QString* errorMessage)
|
||||||
|
{
|
||||||
|
QString localError;
|
||||||
|
if (writeBytesToFile(path, data, &localError))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
errorMessage->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
if (isUserDataPath(path))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = localError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (data.size() > 24 * 1024)
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = QString("File is too large for elevated inline write: %1 bytes. Original error: %2")
|
||||||
|
.arg(data.size()).arg(localError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return writeBytesWithElevation(path, data, localError, errorMessage);
|
||||||
|
#else
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = localError;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::removeFileWithElevationIfNeeded(const QString& path, QString* errorMessage)
|
||||||
|
{
|
||||||
|
QString localError;
|
||||||
|
if (removeFile(path, &localError))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
errorMessage->clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
|
if (isUserDataPath(path))
|
||||||
|
{
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = localError;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return removeFileWithElevation(path, localError, errorMessage);
|
||||||
|
#else
|
||||||
|
if (errorMessage)
|
||||||
|
*errorMessage = localError;
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
ConfigHelper::ConfigHelper()
|
ConfigHelper::ConfigHelper()
|
||||||
{
|
{
|
||||||
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
|
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
|
||||||
|
m_registryInstallId = QString::fromLatin1(QCryptographicHash::hash(
|
||||||
|
QDir::cleanPath(QApplication::applicationDirPath()).toUtf8(),
|
||||||
|
QCryptographicHash::Sha256).toHex());
|
||||||
migrateLegacyIniIfNeeded();
|
migrateLegacyIniIfNeeded();
|
||||||
|
syncRegistryFromConfigFileIfChanged();
|
||||||
qDebug() << "Loading app config path:" << m_configPath;
|
qDebug() << "Loading app config path:" << m_configPath;
|
||||||
qDebug() << "File exists?" << QFile::exists(m_configPath);
|
qDebug() << "File exists?" << QFile::exists(m_configPath);
|
||||||
|
qDebug() << "Registry installation id:" << m_registryInstallId;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ConfigHelper::configPath() const
|
QString ConfigHelper::configPath() const
|
||||||
@@ -41,9 +502,38 @@ QString ConfigHelper::runtimeRoot() const
|
|||||||
return QDir::cleanPath(QApplication::applicationDirPath());
|
return QDir::cleanPath(QApplication::applicationDirPath());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::dataRoot() const
|
||||||
|
{
|
||||||
|
QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
|
||||||
|
if (base.isEmpty())
|
||||||
|
base = QDir::homePath();
|
||||||
|
return QDir::cleanPath(QDir(base).filePath(
|
||||||
|
QStringLiteral("SimCAE/HubUpdateClient/installations/%1").arg(m_registryInstallId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::dataConfigDir() const
|
||||||
|
{
|
||||||
|
return QDir(dataRoot()).filePath(QStringLiteral("config"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::clientIdentityPath() const
|
||||||
|
{
|
||||||
|
return QDir(dataConfigDir()).filePath(QStringLiteral("client_identity.dat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::policyPath() const
|
||||||
|
{
|
||||||
|
return QDir(dataConfigDir()).filePath(QStringLiteral("version_policy.dat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::localStatePath() const
|
||||||
|
{
|
||||||
|
return QDir(dataConfigDir()).filePath(QStringLiteral("local_state.json"));
|
||||||
|
}
|
||||||
|
|
||||||
QString ConfigHelper::updateRoot() const
|
QString ConfigHelper::updateRoot() const
|
||||||
{
|
{
|
||||||
return QDir(runtimeRoot()).filePath("update");
|
return QDir(dataRoot()).filePath("update");
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ConfigHelper::runtimeRelativePath() const
|
QString ConfigHelper::runtimeRelativePath() const
|
||||||
@@ -62,9 +552,227 @@ QString ConfigHelper::lastError() const
|
|||||||
return m_error;
|
return m_error;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
void ConfigHelper::enterRegistryGroup(QSettings& settings) const
|
||||||
{
|
{
|
||||||
Q_UNUSED(section);
|
settings.beginGroup(kRegistryInstallationsGroup);
|
||||||
|
settings.beginGroup(m_registryInstallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::syncRegistryFromConfigFileIfChanged()
|
||||||
|
{
|
||||||
|
QFile file(m_configPath);
|
||||||
|
if (!file.exists())
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Cannot open config for reading: %1").arg(file.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonParseError parseError;
|
||||||
|
const QByteArray raw = file.readAll();
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
||||||
|
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Invalid config JSON: %1").arg(parseError.errorString());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject config = registryManagedConfigObject(document.object());
|
||||||
|
const QByteArray normalizedConfig = QJsonDocument(config).toJson(QJsonDocument::Compact);
|
||||||
|
const QString sourceHash = QString::fromLatin1(
|
||||||
|
QCryptographicHash::hash(normalizedConfig, QCryptographicHash::Sha256).toHex());
|
||||||
|
|
||||||
|
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
|
||||||
|
kRegistryOrganization, kRegistryApplication);
|
||||||
|
enterRegistryGroup(settings);
|
||||||
|
settings.beginGroup(kRegistryMetaGroup);
|
||||||
|
const QString previousHash = settings.value(kRegistrySourceHashKey).toString();
|
||||||
|
settings.endGroup();
|
||||||
|
|
||||||
|
if (previousHash == sourceHash)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (config.isEmpty())
|
||||||
|
{
|
||||||
|
settings.beginGroup(kRegistryMetaGroup);
|
||||||
|
settings.setValue(kRegistrySourceHashKey, sourceHash);
|
||||||
|
settings.setValue(kRegistrySourcePathKey, QDir::toNativeSeparators(QFileInfo(m_configPath).absoluteFilePath()));
|
||||||
|
settings.setValue(kRegistryRuntimeRootKey, QDir::toNativeSeparators(QApplication::applicationDirPath()));
|
||||||
|
settings.setValue(kRegistryImportedAtKey, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||||
|
settings.endGroup();
|
||||||
|
settings.sync();
|
||||||
|
if (settings.status() != QSettings::NoError)
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Cannot sync empty app_config.json marker to registry.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_error.clear();
|
||||||
|
qDebug() << "app_config.json is empty; keeping existing registry configuration.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool configChangedAfterPreviousImport = !previousHash.isEmpty();
|
||||||
|
|
||||||
|
settings.beginGroup(kRegistryConfigGroup);
|
||||||
|
settings.remove(QString());
|
||||||
|
for (auto it = config.constBegin(); it != config.constEnd(); ++it)
|
||||||
|
settings.setValue(it.key(), it.value().toVariant());
|
||||||
|
settings.endGroup();
|
||||||
|
|
||||||
|
settings.beginGroup(kRegistryMetaGroup);
|
||||||
|
settings.setValue(kRegistrySourceHashKey, sourceHash);
|
||||||
|
settings.setValue(kRegistrySourcePathKey, QDir::toNativeSeparators(QFileInfo(m_configPath).absoluteFilePath()));
|
||||||
|
settings.setValue(kRegistryRuntimeRootKey, QDir::toNativeSeparators(QApplication::applicationDirPath()));
|
||||||
|
settings.setValue(kRegistryImportedAtKey, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||||
|
settings.endGroup();
|
||||||
|
settings.sync();
|
||||||
|
|
||||||
|
if (settings.status() != QSettings::NoError)
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Cannot sync config to registry.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_error.clear();
|
||||||
|
qDebug() << "Synced app_config.json to registry installation id:" << m_registryInstallId;
|
||||||
|
|
||||||
|
if (configChangedAfterPreviousImport)
|
||||||
|
{
|
||||||
|
const QStringList staleFiles{
|
||||||
|
clientIdentityPath(),
|
||||||
|
policyPath(),
|
||||||
|
localStatePath()
|
||||||
|
};
|
||||||
|
for (const QString& staleFile : staleFiles)
|
||||||
|
{
|
||||||
|
QString removeError;
|
||||||
|
if (!removeFile(staleFile, &removeError))
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Cannot remove stale runtime file after config change: %1. %2")
|
||||||
|
.arg(staleFile, removeError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qDebug() << "Removed stale client identity, policy and local state after app_config.json changed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.isEmpty() && !sanitizeConfigFileAfterImport(settings))
|
||||||
|
{
|
||||||
|
qWarning() << "Cannot sanitize app_config.json after registry import:" << m_error;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::sanitizeConfigFileAfterImport(QSettings& settings)
|
||||||
|
{
|
||||||
|
const QJsonObject emptyConfig;
|
||||||
|
const QByteArray normalizedEmptyConfig = QJsonDocument(emptyConfig).toJson(QJsonDocument::Compact);
|
||||||
|
const QByteArray emptyFileBytes = QJsonDocument(emptyConfig).toJson(QJsonDocument::Indented);
|
||||||
|
const QString sanitizedHash = QString::fromLatin1(
|
||||||
|
QCryptographicHash::hash(normalizedEmptyConfig, QCryptographicHash::Sha256).toHex());
|
||||||
|
|
||||||
|
QString writeError;
|
||||||
|
if (!writeBytesToFile(m_configPath, emptyFileBytes, &writeError))
|
||||||
|
{
|
||||||
|
// 清空 app_config.json 只是为了减少明文配置暴露,不是启动必需步骤。
|
||||||
|
// 如果安装目录或文件只读,不再为了清空源配置弹 UAC;运行配置已经写入 HKCU 注册表。
|
||||||
|
m_error = QStringLiteral("Cannot clear app_config.json after registry import without elevation: %1").arg(writeError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.beginGroup(kRegistryMetaGroup);
|
||||||
|
settings.setValue(kRegistrySourceHashKey, sanitizedHash);
|
||||||
|
settings.setValue(kRegistrySourcePathKey, QDir::toNativeSeparators(QFileInfo(m_configPath).absoluteFilePath()));
|
||||||
|
settings.setValue(kRegistryRuntimeRootKey, QDir::toNativeSeparators(QApplication::applicationDirPath()));
|
||||||
|
settings.setValue(kRegistryImportedAtKey, QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
|
||||||
|
settings.endGroup();
|
||||||
|
settings.sync();
|
||||||
|
|
||||||
|
if (settings.status() != QSettings::NoError)
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Cannot update registry source hash after clearing app_config.json.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_error.clear();
|
||||||
|
qDebug() << "Cleared app_config.json after importing configuration to registry.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::removeRegistryValue(const QString& key) const
|
||||||
|
{
|
||||||
|
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
|
||||||
|
kRegistryOrganization, kRegistryApplication);
|
||||||
|
enterRegistryGroup(settings);
|
||||||
|
settings.beginGroup(kRegistryConfigGroup);
|
||||||
|
settings.remove(key);
|
||||||
|
settings.endGroup();
|
||||||
|
settings.sync();
|
||||||
|
return settings.status() == QSettings::NoError;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::readEmbeddedValue(const QString& key) const
|
||||||
|
{
|
||||||
|
if (key != kApiBaseUrlKey)
|
||||||
|
return QString();
|
||||||
|
|
||||||
|
QFile file(kEmbeddedServerConfigPath);
|
||||||
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
|
return QString();
|
||||||
|
|
||||||
|
QJsonParseError error;
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
|
||||||
|
if (error.error != QJsonParseError::NoError || !document.isObject())
|
||||||
|
return QString();
|
||||||
|
|
||||||
|
return document.object().value(key).toVariant().toString().trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::readRegistryValue(const QString& key, QString* value) const
|
||||||
|
{
|
||||||
|
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
|
||||||
|
kRegistryOrganization, kRegistryApplication);
|
||||||
|
enterRegistryGroup(settings);
|
||||||
|
settings.beginGroup(kRegistryConfigGroup);
|
||||||
|
if (!settings.contains(key))
|
||||||
|
{
|
||||||
|
settings.endGroup();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (value)
|
||||||
|
*value = settings.value(key).toString();
|
||||||
|
settings.endGroup();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigHelper::writeRegistryValue(const QString& key, const QString& value)
|
||||||
|
{
|
||||||
|
QSettings settings(QSettings::NativeFormat, QSettings::UserScope,
|
||||||
|
kRegistryOrganization, kRegistryApplication);
|
||||||
|
enterRegistryGroup(settings);
|
||||||
|
settings.beginGroup(kRegistryConfigGroup);
|
||||||
|
settings.setValue(key, value);
|
||||||
|
settings.endGroup();
|
||||||
|
settings.sync();
|
||||||
|
|
||||||
|
if (settings.status() != QSettings::NoError)
|
||||||
|
{
|
||||||
|
m_error = QStringLiteral("Cannot write config value to registry: %1").arg(key);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_error.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::readFileValue(const QString& key) const
|
||||||
|
{
|
||||||
|
if (!isRegistryManagedConfigKey(key))
|
||||||
|
return QString();
|
||||||
|
|
||||||
QFile file(m_configPath);
|
QFile file(m_configPath);
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
return QString();
|
return QString();
|
||||||
@@ -77,59 +785,29 @@ QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
|||||||
return document.object().value(key).toVariant().toString();
|
return document.object().value(key).toVariant().toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ConfigHelper::getValue(const QString& section, const QString& key) const
|
||||||
|
{
|
||||||
|
Q_UNUSED(section);
|
||||||
|
if (!isRegistryManagedConfigKey(key))
|
||||||
|
return QString();
|
||||||
|
|
||||||
|
QString value;
|
||||||
|
if (readRegistryValue(key, &value))
|
||||||
|
return value;
|
||||||
|
|
||||||
|
value = readFileValue(key).trimmed();
|
||||||
|
if (!value.isEmpty())
|
||||||
|
return value;
|
||||||
|
|
||||||
|
return readEmbeddedValue(key);
|
||||||
|
}
|
||||||
|
|
||||||
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
|
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
|
||||||
{
|
{
|
||||||
Q_UNUSED(section);
|
Q_UNUSED(section);
|
||||||
m_error.clear();
|
m_error.clear();
|
||||||
|
|
||||||
QFile input(m_configPath);
|
return writeRegistryValue(key, value);
|
||||||
QJsonObject config;
|
|
||||||
if (input.exists())
|
|
||||||
{
|
|
||||||
if (!input.open(QIODevice::ReadOnly))
|
|
||||||
{
|
|
||||||
m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
QJsonParseError parseError;
|
|
||||||
const QByteArray raw = input.readAll();
|
|
||||||
input.close();
|
|
||||||
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
|
|
||||||
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
|
||||||
{
|
|
||||||
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
config = document.object();
|
|
||||||
}
|
|
||||||
|
|
||||||
config.insert(key, value);
|
|
||||||
QDir dir(QFileInfo(m_configPath).path());
|
|
||||||
if (!dir.exists() && !dir.mkpath("."))
|
|
||||||
{
|
|
||||||
m_error = QString("Cannot create config directory: %1").arg(dir.path());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
QSaveFile output(m_configPath);
|
|
||||||
if (!output.open(QIODevice::WriteOnly))
|
|
||||||
{
|
|
||||||
m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
|
|
||||||
if (output.write(payload) != payload.size())
|
|
||||||
{
|
|
||||||
m_error = QString("Cannot write full config file: %1").arg(output.errorString());
|
|
||||||
output.cancelWriting();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!output.commit())
|
|
||||||
{
|
|
||||||
m_error = QString("Cannot commit config file: %1").arg(output.errorString());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
bool ConfigHelper::migrateLegacyIniIfNeeded()
|
||||||
@@ -149,25 +827,34 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
|
|||||||
};
|
};
|
||||||
|
|
||||||
copyText("App", "app_id");
|
copyText("App", "app_id");
|
||||||
copyText("App", "app_name", "Marsco Demo App");
|
copyText("App", "product_code", ini.value("App/app_id").toString());
|
||||||
|
copyText("App", "app_name", "SimCAE");
|
||||||
copyText("App", "channel", "stable");
|
copyText("App", "channel", "stable");
|
||||||
copyText("App", "current_version", "1.0.0");
|
copyText("App", "current_version", "1.0.0");
|
||||||
copyText("App", "client_protocol", "3");
|
copyText("App", "client_protocol", "3");
|
||||||
copyText("App", "launch_token");
|
|
||||||
copyText("License", "license_key");
|
|
||||||
copyText("Server", "api_base_url");
|
|
||||||
copyText("Server", "client_token");
|
copyText("Server", "client_token");
|
||||||
|
copyText("App", "launch_token");
|
||||||
|
copyText("Server", "api_base_url");
|
||||||
copyText("Update", "request_timeout_ms", "5000");
|
copyText("Update", "request_timeout_ms", "5000");
|
||||||
copyText("Update", "temp_folder", "update_temp");
|
copyText("Update", "temp_folder", "update_temp");
|
||||||
copyText("Update", "device_id");
|
copyText("Update", "device_id");
|
||||||
copyText("Runtime", "install_root", ".");
|
copyText("Runtime", "install_root", ".");
|
||||||
copyText("Runtime", "main_executable", "MainApp.exe");
|
copyText("Runtime", "main_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "MainApp"));
|
||||||
copyText("Runtime", "launcher_executable", "Launcher.exe");
|
copyText("Runtime", "launcher_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Launcher"));
|
||||||
copyText("Runtime", "updater_executable", "Updater.exe");
|
copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater"));
|
||||||
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
|
copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap"));
|
||||||
copyText("Runtime", "health_check_timeout_ms", "15000");
|
copyText("Runtime", "health_check_timeout_ms", "15000");
|
||||||
|
copyText("Security", "require_manifest_signature", "false");
|
||||||
|
copyText("Security", "verify_installed_on_start", "false");
|
||||||
|
copyText("Platform", "abi");
|
||||||
|
#ifdef Q_OS_WIN
|
||||||
config.insert("platform", "windows");
|
config.insert("platform", "windows");
|
||||||
config.insert("arch", "x64");
|
#elif defined(Q_OS_LINUX)
|
||||||
|
config.insert("platform", "linux");
|
||||||
|
#else
|
||||||
|
config.insert("platform", "unknown");
|
||||||
|
#endif
|
||||||
|
config.insert("arch", "x86_64");
|
||||||
|
|
||||||
QDir().mkpath(QFileInfo(m_configPath).path());
|
QDir().mkpath(QFileInfo(m_configPath).path());
|
||||||
QSaveFile output(m_configPath);
|
QSaveFile output(m_configPath);
|
||||||
|
|||||||
@@ -1,15 +1,29 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
#include <QByteArray>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
|
class QSettings;
|
||||||
|
|
||||||
class ConfigHelper
|
class ConfigHelper
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static ConfigHelper& instance();
|
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;
|
QString getValue(const QString& section, const QString& key) const;
|
||||||
bool setValue(const QString& section, const QString& key, const QString& value);
|
bool setValue(const QString& section, const QString& key, const QString& value);
|
||||||
QString configPath() const;
|
QString configPath() const;
|
||||||
QString installRoot() const;
|
QString installRoot() const;
|
||||||
QString runtimeRoot() const;
|
QString runtimeRoot() const;
|
||||||
|
QString dataRoot() const;
|
||||||
|
QString dataConfigDir() const;
|
||||||
|
QString clientIdentityPath() const;
|
||||||
|
QString policyPath() const;
|
||||||
|
QString localStatePath() const;
|
||||||
QString updateRoot() const;
|
QString updateRoot() const;
|
||||||
QString runtimeRelativePath() const;
|
QString runtimeRelativePath() const;
|
||||||
QString lastError() const;
|
QString lastError() const;
|
||||||
@@ -17,6 +31,15 @@ public:
|
|||||||
private:
|
private:
|
||||||
ConfigHelper();
|
ConfigHelper();
|
||||||
bool migrateLegacyIniIfNeeded();
|
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_configPath;
|
||||||
|
QString m_registryInstallId;
|
||||||
QString m_error;
|
QString m_error;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
#include "DeviceIdentityHelper.h"
|
|
||||||
#include "ConfigHelper.h"
|
|
||||||
#include <QCryptographicHash>
|
|
||||||
#include <QDateTime>
|
|
||||||
#include <QDir>
|
|
||||||
#include <QEventLoop>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QJsonDocument>
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QNetworkAccessManager>
|
|
||||||
#include <QNetworkReply>
|
|
||||||
#include <QNetworkRequest>
|
|
||||||
#include <QSaveFile>
|
|
||||||
#include <QSysInfo>
|
|
||||||
#include <QUuid>
|
|
||||||
#include <QTimer>
|
|
||||||
#ifdef HAVE_OPENSSL
|
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include <openssl/pem.h>
|
|
||||||
#endif
|
|
||||||
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
|
|
||||||
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
|
|
||||||
QString DeviceIdentityHelper::errorString() const{return m_error;}
|
|
||||||
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
|
|
||||||
#ifndef HAVE_OPENSSL
|
|
||||||
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
|
|
||||||
#else
|
|
||||||
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
|
|
||||||
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
|
|
||||||
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
|
|
||||||
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
|
|
||||||
}
|
|
||||||
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
|
|
||||||
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
|
|
||||||
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
|
|
||||||
const QString trimmedBase=base.trimmed();
|
|
||||||
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
|
|
||||||
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
|
|
||||||
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set it to the update server address, for example http://192.168.229.128:8000";return false;}
|
|
||||||
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
|
|
||||||
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
|
|
||||||
ConfigHelper& config=ConfigHelper::instance();
|
|
||||||
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
|
|
||||||
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
|
|
||||||
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));QEventLoop loop;bool timeoutOk=false;int timeoutMs=ConfigHelper::instance().getValue("Update","request_timeout_ms").toInt(&timeoutOk);if(!timeoutOk||timeoutMs<1000)timeoutMs=5000;QTimer timer;timer.setSingleShot(true);QObject::connect(&timer,&QTimer::timeout,[&](){if(reply&&reply->isRunning())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error=QString("cannot save device credential to %1: %2").arg(path,out.errorString());return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#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;
|
|
||||||
};
|
|
||||||
+62
-7
@@ -1,5 +1,20 @@
|
|||||||
#include "FileHelper.h"
|
#include "FileHelper.h"
|
||||||
#include <QDebug>
|
#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)
|
bool FileHelper::createDir(const QString &path)
|
||||||
{
|
{
|
||||||
@@ -15,7 +30,7 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
|||||||
{
|
{
|
||||||
if (!QFile::remove(dst))
|
if (!QFile::remove(dst))
|
||||||
{
|
{
|
||||||
qDebug() << "无法删除旧文件:" << dst;
|
qDebug() << "Cannot remove old file:" << dst;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -24,19 +39,59 @@ bool FileHelper::copyFileOverwrite(const QString &src, const QString &dst)
|
|||||||
|
|
||||||
bool FileHelper::isProcessRunning(const QString &exeName)
|
bool FileHelper::isProcessRunning(const QString &exeName)
|
||||||
{
|
{
|
||||||
|
const QString imageName = processImageName(exeName);
|
||||||
|
if (imageName.isEmpty())
|
||||||
|
return false;
|
||||||
|
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("tasklist");
|
#ifdef Q_OS_WIN
|
||||||
|
process.start(QStringLiteral("tasklist"), QStringList{
|
||||||
|
QStringLiteral("/FI"),
|
||||||
|
QStringLiteral("IMAGENAME eq %1").arg(imageName)
|
||||||
|
});
|
||||||
process.waitForFinished();
|
process.waitForFinished();
|
||||||
QString output = process.readAllStandardOutput();
|
const QString output = QString::fromLocal8Bit(process.readAllStandardOutput());
|
||||||
return output.contains(exeName, Qt::CaseInsensitive);
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
bool FileHelper::killProcess(const QString &exeName)
|
bool FileHelper::killProcess(const QString &exeName)
|
||||||
{
|
{
|
||||||
if (!isProcessRunning(exeName))
|
if (!isProcessRunning(exeName))
|
||||||
return true;
|
return true;
|
||||||
|
const QString imageName = processImageName(exeName);
|
||||||
|
if (imageName.isEmpty())
|
||||||
|
return true;
|
||||||
|
|
||||||
QProcess process;
|
QProcess process;
|
||||||
process.start("taskkill /f /im " + exeName);
|
#ifdef Q_OS_WIN
|
||||||
process.waitForFinished(1000);
|
process.start(QStringLiteral("taskkill"), QStringList{
|
||||||
return !isProcessRunning(exeName);
|
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;
|
||||||
}
|
}
|
||||||
+103
-36
@@ -4,33 +4,53 @@
|
|||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QJsonParseError>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
namespace {
|
||||||
std::function<void(int code, const QJsonObject& resp)> callback)
|
|
||||||
|
int requestTimeoutMs()
|
||||||
{
|
{
|
||||||
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;
|
bool timeoutOk = false;
|
||||||
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
|
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
|
||||||
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
|
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;
|
||||||
QTimer timer;
|
QTimer timer;
|
||||||
timer.setSingleShot(true);
|
timer.setSingleShot(true);
|
||||||
QObject::connect(&timer, &QTimer::timeout, [&]() {
|
QObject::connect(&timer, &QTimer::timeout, [&]() {
|
||||||
@@ -39,26 +59,73 @@ void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
|
|||||||
reply->abort();
|
reply->abort();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||||
timer.start(timeoutMs);
|
timer.start(requestTimeoutMs());
|
||||||
loop.exec();
|
loop.exec();
|
||||||
timer.stop();
|
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;
|
int retCode = 0;
|
||||||
QJsonObject retObj;
|
QJsonObject retObj;
|
||||||
|
readJsonReply(reply, &retCode, &retObj);
|
||||||
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
||||||
const QByteArray respData = reply->readAll();
|
callback(retCode, retObj);
|
||||||
if (!respData.isEmpty()) {
|
|
||||||
qDebug() << "Server raw response:" << respData;
|
reply->deleteLater();
|
||||||
retObj = QJsonDocument::fromJson(respData).object();
|
manager->deleteLater();
|
||||||
}
|
}
|
||||||
if (reply->error() != QNetworkReply::NoError)
|
|
||||||
{
|
void HttpHelper::getRequest(const QString& url,
|
||||||
qDebug() << "Network error code:" << reply->error();
|
std::function<void(int code, const QJsonObject& resp)> callback)
|
||||||
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
|
{
|
||||||
}
|
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);
|
||||||
|
|
||||||
callback(retCode, retObj);
|
callback(retCode, retObj);
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -7,11 +7,19 @@
|
|||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
class HttpHelper
|
class HttpHelper
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
// 每次调用独立创建manager,不做成成员变量
|
// Create an independent manager for each call instead of keeping it as a member.
|
||||||
static void postRequest(const QString& url, const QJsonObject& jsonBody,
|
static void postRequest(const QString& url, const QJsonObject& jsonBody,
|
||||||
std::function<void(int code, const QJsonObject& resp)> callback);
|
std::function<void(int code, const QJsonObject& resp)> callback);
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
+162
-38
@@ -1,11 +1,13 @@
|
|||||||
#include "IntegrityHelper.h"
|
#include "IntegrityHelper.h"
|
||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
|
#include "UpdatePathPolicy.h"
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QSet>
|
#include <QSet>
|
||||||
@@ -14,6 +16,32 @@
|
|||||||
#include <openssl/pem.h>
|
#include <openssl/pem.h>
|
||||||
#endif
|
#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)
|
IntegrityHelper::IntegrityHelper(const QString& installDir)
|
||||||
: m_installDir(QDir::cleanPath(installDir)) {}
|
: m_installDir(QDir::cleanPath(installDir)) {}
|
||||||
|
|
||||||
@@ -21,28 +49,13 @@ QString IntegrityHelper::errorString() const { return m_error; }
|
|||||||
|
|
||||||
bool IntegrityHelper::safeRelativePath(const QString& path) const
|
bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||||
{
|
{
|
||||||
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
|
return UpdatePathPolicy::isSafeRelativePath(path);
|
||||||
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
|
|
||||||
&& !clean.startsWith("../") && !clean.contains(":");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||||
{
|
{
|
||||||
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
return UpdatePathPolicy::isFullUpdateProtectedPath(
|
||||||
QSet<QString> protectedPaths{
|
path, ConfigHelper::instance().runtimeRelativePath());
|
||||||
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
|
||||||
"config/client_identity.dat", "config/version_policy.dat"
|
|
||||||
};
|
|
||||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
|
||||||
if (!runtimePrefix.isEmpty()) {
|
|
||||||
const QStringList runtimeProtected{
|
|
||||||
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
|
||||||
"config/client_identity.dat", "config/version_policy.dat"
|
|
||||||
};
|
|
||||||
for (const QString& protectedPath : runtimeProtected)
|
|
||||||
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
|
||||||
}
|
|
||||||
return protectedPaths.contains(p);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QString IntegrityHelper::sha256(const QString& filePath) const
|
QString IntegrityHelper::sha256(const QString& filePath) const
|
||||||
@@ -57,18 +70,31 @@ QString IntegrityHelper::sha256(const QString& filePath) const
|
|||||||
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
|
bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64)
|
||||||
{
|
{
|
||||||
#ifndef HAVE_OPENSSL
|
#ifndef HAVE_OPENSSL
|
||||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
Q_UNUSED(payload); Q_UNUSED(signatureBase64);
|
||||||
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||||
|
"Cannot verify signed manifest because OpenSSL support is unavailable. Stage: installed version verification.");
|
||||||
|
return false;
|
||||||
#else
|
#else
|
||||||
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
|
QString keyPath = QDir(m_installDir).filePath("config/manifest_public_key.pem");
|
||||||
if (!QFile::exists(keyPath))
|
if (!QFile::exists(keyPath))
|
||||||
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
|
keyPath = QFileInfo(ConfigHelper::instance().configPath()).dir().filePath("manifest_public_key.pem");
|
||||||
QFile keyFile(keyPath);
|
QFile keyFile(keyPath);
|
||||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; }
|
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;
|
||||||
|
}
|
||||||
const QByteArray keyData = keyFile.readAll();
|
const QByteArray keyData = keyFile.readAll();
|
||||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||||
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||||
if (bio) BIO_free(bio);
|
if (bio) BIO_free(bio);
|
||||||
if (!key) { m_error = "manifest public key invalid"; return false; }
|
if (!key) {
|
||||||
|
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||||
|
"Manifest public key is invalid. Stage: installed version verification. Public key path: %1.")
|
||||||
|
.arg(keyPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||||
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||||
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
|
const bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
|
||||||
@@ -76,7 +102,10 @@ bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString&
|
|||||||
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
|
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1;
|
||||||
if (ctx) EVP_MD_CTX_free(ctx);
|
if (ctx) EVP_MD_CTX_free(ctx);
|
||||||
EVP_PKEY_free(key);
|
EVP_PKEY_free(key);
|
||||||
if (!ok) m_error = "manifest RSA signature invalid";
|
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.");
|
||||||
|
}
|
||||||
return ok;
|
return ok;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -85,6 +114,8 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
|||||||
const QString& version)
|
const QString& version)
|
||||||
{
|
{
|
||||||
m_error.clear();
|
m_error.clear();
|
||||||
|
// Manifest cache 来自服务端发布版本时生成的签名清单。
|
||||||
|
// 客户端先验签 Manifest,再逐个校验文件 SHA256,防止升级文件被篡改或漏替换。
|
||||||
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
|
QString cachePath = QDir(ConfigHelper::instance().updateRoot()).filePath(
|
||||||
"manifest_cache/manifest_" + version + ".json");
|
"manifest_cache/manifest_" + version + ".json");
|
||||||
const QString legacyCachePath = QDir(m_installDir).filePath(
|
const QString legacyCachePath = QDir(m_installDir).filePath(
|
||||||
@@ -92,48 +123,130 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
|||||||
if (!QFile::exists(cachePath))
|
if (!QFile::exists(cachePath))
|
||||||
cachePath = legacyCachePath;
|
cachePath = legacyCachePath;
|
||||||
QFile cache(cachePath);
|
QFile cache(cachePath);
|
||||||
if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; }
|
if (!cache.open(QIODevice::ReadOnly)) {
|
||||||
|
m_error = 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);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
QJsonParseError wrapperError;
|
QJsonParseError wrapperError;
|
||||||
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
|
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError);
|
||||||
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
|
if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
|
||||||
m_error = "manifest cache JSON invalid"; return false;
|
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;
|
||||||
}
|
}
|
||||||
const QJsonObject wrapper = wrapperDoc.object();
|
const QJsonObject wrapper = wrapperDoc.object();
|
||||||
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
|
||||||
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
|
if (manifestText.isEmpty())
|
||||||
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
QJsonParseError manifestError;
|
QJsonParseError manifestError;
|
||||||
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
|
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
|
||||||
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
|
if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) {
|
||||||
m_error = "signed manifest payload invalid"; return false;
|
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;
|
||||||
}
|
}
|
||||||
const QJsonObject manifest = manifestDoc.object();
|
const QJsonObject manifest = manifestDoc.object();
|
||||||
if (manifest.value("app_id").toString() != appId
|
const QString manifestProduct = manifest.value("productCode").toString(
|
||||||
|
manifest.value("app_id").toString());
|
||||||
|
if (manifestProduct != appId
|
||||||
|| manifest.value("channel").toString() != channel
|
|| manifest.value("channel").toString() != channel
|
||||||
|| manifest.value("version").toString() != version) {
|
|| manifest.value("version").toString() != version) {
|
||||||
m_error = "manifest identity does not match local application"; return false;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
QSet<QString> declaredExecutables;
|
QSet<QString> declaredExecutables;
|
||||||
|
QSet<QString> optionalComponentDirs;
|
||||||
for (const QJsonValue& value : manifest.value("files").toArray()) {
|
for (const QJsonValue& value : manifest.value("files").toArray()) {
|
||||||
const QJsonObject item = value.toObject();
|
const QJsonObject item = value.toObject();
|
||||||
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||||
if (!safeRelativePath(path)) { m_error = "unsafe manifest path: " + path; return false; }
|
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 (runtimeProtectedPath(path)) continue;
|
if (runtimeProtectedPath(path)) continue;
|
||||||
const QString fullPath = QDir(m_installDir).filePath(path);
|
const QString fullPath = QDir(m_installDir).filePath(path);
|
||||||
if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; }
|
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;
|
||||||
|
}
|
||||||
const QString expected = item.value("sha256").toString();
|
const QString expected = item.value("sha256").toString();
|
||||||
const QString actual = sha256(fullPath);
|
const QString actual = sha256(fullPath);
|
||||||
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
|
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
|
||||||
m_error = "file hash mismatch: " + path; return false;
|
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;
|
||||||
}
|
}
|
||||||
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
|
|
||||||
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QDir root(m_installDir);
|
QDir root(m_installDir);
|
||||||
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
|
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
|
||||||
|
// 除了清单中声明的文件,还要拒绝额外出现的 exe/dll。
|
||||||
|
// 这能降低被人偷偷塞插件或可执行文件的风险。
|
||||||
while (it.hasNext()) {
|
while (it.hasNext()) {
|
||||||
const QString fullPath = it.next();
|
const QString fullPath = it.next();
|
||||||
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath));
|
||||||
@@ -144,9 +257,20 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
|||||||
|| folded.startsWith(runtimePrefix + "/update_temp/"));
|
|| folded.startsWith(runtimePrefix + "/update_temp/"));
|
||||||
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
||||||
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
||||||
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
|
bool optionalComponentFile = false;
|
||||||
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
|
for (const QString& prefix : optionalComponentDirs) {
|
||||||
m_error = "undeclared executable or plugin: " + relative; return false;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+36
-20
@@ -1,28 +1,28 @@
|
|||||||
#include "LocalStateHelper.h"
|
#include "LocalStateHelper.h"
|
||||||
#include <QFile>
|
#include "ConfigHelper.h"
|
||||||
#include <QFileInfo>
|
#include <QCoreApplication>
|
||||||
#include <QJsonDocument>
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
|
||||||
LocalStateHelper::LocalStateHelper(const QString& baseDir)
|
LocalStateHelper::LocalStateHelper(const QString& baseDir)
|
||||||
: m_baseDir(baseDir)
|
: m_baseDir(baseDir)
|
||||||
, m_filePath(baseDir + "/config/local_state.json")
|
, m_filePath(ConfigHelper::instance().localStatePath())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LocalStateHelper::loadState(const QString& relativePath)
|
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);
|
QFile file(m_filePath);
|
||||||
if (!file.exists())
|
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{
|
m_state = QJsonObject{
|
||||||
{"max_policy_seq", 0},
|
{"max_policy_seq", 0},
|
||||||
{"last_success_run_at", QString()},
|
{"last_success_run_at", QString()},
|
||||||
@@ -32,7 +32,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
|||||||
m_loaded = true;
|
m_loaded = true;
|
||||||
if (!saveState())
|
if (!saveState())
|
||||||
{
|
{
|
||||||
m_error = QString("Cannot write new state file: %1").arg(m_filePath);
|
m_error = QCoreApplication::translate(
|
||||||
|
"LocalStateHelper",
|
||||||
|
"Cannot create local state file: %1. Error: %2.")
|
||||||
|
.arg(m_filePath, m_error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -40,7 +43,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
|||||||
|
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
{
|
{
|
||||||
m_error = QString("Cannot open state file: %1").arg(m_filePath);
|
m_error = QCoreApplication::translate(
|
||||||
|
"LocalStateHelper",
|
||||||
|
"Cannot open local state file: %1. Error: %2.")
|
||||||
|
.arg(m_filePath, file.errorString());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +57,10 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
|||||||
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
|
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
|
||||||
{
|
{
|
||||||
m_error = QString("Invalid state JSON: %1").arg(parseError.errorString());
|
m_error = QCoreApplication::translate(
|
||||||
|
"LocalStateHelper",
|
||||||
|
"Local state file is not valid JSON. File: %1. JSON error: %2.")
|
||||||
|
.arg(m_filePath, parseError.errorString());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,17 +72,24 @@ bool LocalStateHelper::loadState(const QString& relativePath)
|
|||||||
bool LocalStateHelper::saveState() const
|
bool LocalStateHelper::saveState() const
|
||||||
{
|
{
|
||||||
if (!m_loaded)
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QJsonDocument doc(m_state);
|
QJsonDocument doc(m_state);
|
||||||
file.write(doc.toJson(QJsonDocument::Indented));
|
QString writeError;
|
||||||
file.close();
|
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();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public:
|
|||||||
private:
|
private:
|
||||||
QString m_baseDir;
|
QString m_baseDir;
|
||||||
QString m_filePath;
|
QString m_filePath;
|
||||||
QString m_error;
|
mutable QString m_error;
|
||||||
QJsonObject m_state;
|
QJsonObject m_state;
|
||||||
bool m_loaded = false;
|
bool m_loaded = false;
|
||||||
};
|
};
|
||||||
|
|||||||
+87
-14
@@ -1,10 +1,12 @@
|
|||||||
#include "PolicyHelper.h"
|
#include "PolicyHelper.h"
|
||||||
|
#include "ConfigHelper.h"
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QSaveFile>
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#ifdef HAVE_OPENSSL
|
#ifdef HAVE_OPENSSL
|
||||||
#include <openssl/evp.h>
|
#include <openssl/evp.h>
|
||||||
@@ -13,14 +15,38 @@
|
|||||||
|
|
||||||
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
|
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)
|
bool PolicyHelper::loadPolicy(const QString& relativePath)
|
||||||
{
|
{
|
||||||
QFile file(m_baseDir + "/" + relativePath);
|
const QString path = resolvePolicyPath(m_baseDir, relativePath, false);
|
||||||
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return 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;
|
||||||
|
}
|
||||||
QJsonParseError error;
|
QJsonParseError error;
|
||||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &error);
|
||||||
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
if (error.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||||
m_error = "Invalid policy JSON: " + error.errorString(); return false;
|
m_error = QCoreApplication::translate(
|
||||||
|
"PolicyHelper",
|
||||||
|
"Signed version policy is not valid JSON. File: %1. JSON error: %2.")
|
||||||
|
.arg(path, error.errorString());
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return loadPolicyObject(doc.object());
|
return loadPolicyObject(doc.object());
|
||||||
}
|
}
|
||||||
@@ -33,10 +59,18 @@ bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& si
|
|||||||
|
|
||||||
bool PolicyHelper::savePolicy(const QString& relativePath) const
|
bool PolicyHelper::savePolicy(const QString& relativePath) const
|
||||||
{
|
{
|
||||||
QSaveFile file(m_baseDir + "/" + relativePath);
|
|
||||||
if (!file.open(QIODevice::WriteOnly)) return false;
|
|
||||||
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
|
const QByteArray bytes = QJsonDocument(m_policy).toJson(QJsonDocument::Indented);
|
||||||
return file.write(bytes) == bytes.size() && file.commit();
|
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;
|
||||||
|
}
|
||||||
|
m_error.clear();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
||||||
@@ -66,34 +100,72 @@ QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
|
|||||||
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
|
bool PolicyHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) const
|
||||||
{
|
{
|
||||||
#ifndef HAVE_OPENSSL
|
#ifndef HAVE_OPENSSL
|
||||||
Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false;
|
Q_UNUSED(payload);
|
||||||
|
Q_UNUSED(signatureBase64);
|
||||||
|
m_error = QCoreApplication::translate(
|
||||||
|
"PolicyHelper",
|
||||||
|
"OpenSSL is unavailable, so the signed version policy cannot be verified.");
|
||||||
|
return false;
|
||||||
#else
|
#else
|
||||||
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
|
QString keyPath = m_baseDir + "/config/manifest_public_key.pem";
|
||||||
QFile keyFile(keyPath);
|
QFile keyFile(keyPath);
|
||||||
if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy public key"; return false; }
|
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;
|
||||||
|
}
|
||||||
const QByteArray keyData = keyFile.readAll();
|
const QByteArray keyData = keyFile.readAll();
|
||||||
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
|
||||||
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
|
||||||
if (bio) BIO_free(bio);
|
if (bio) BIO_free(bio);
|
||||||
if (!key) { m_error = "Invalid policy public key"; return false; }
|
if (!key) {
|
||||||
|
m_error = QCoreApplication::translate(
|
||||||
|
"PolicyHelper",
|
||||||
|
"Version policy public key is invalid: %1.")
|
||||||
|
.arg(keyPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
||||||
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
|
||||||
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
|
bool ok = ctx && EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, key) == 1
|
||||||
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
|
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
|
||||||
&& EVP_DigestVerifyFinal(ctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.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 (ctx) EVP_MD_CTX_free(ctx); EVP_PKEY_free(key);
|
||||||
if (!ok) m_error = "Invalid RSA policy signature";
|
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.");
|
||||||
|
}
|
||||||
return ok;
|
return ok;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
bool PolicyHelper::isValid() const
|
bool PolicyHelper::isValid() const
|
||||||
{
|
{
|
||||||
if (!m_loaded) return false;
|
if (!m_loaded) {
|
||||||
|
m_error = QCoreApplication::translate("PolicyHelper", "Version policy has not been loaded.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const QStringList required{"app_id","channel","current_version","policy_seq","allow_run",
|
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"};
|
"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 = "Missing policy field: " + key; return false; }
|
for (const QString& key : required) {
|
||||||
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
|
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;
|
||||||
|
}
|
||||||
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
|
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8();
|
||||||
return verifySignature(payload, m_policy.value("signature").toString());
|
return verifySignature(payload, m_policy.value("signature").toString());
|
||||||
}
|
}
|
||||||
@@ -107,6 +179,7 @@ bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_
|
|||||||
bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); }
|
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::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); }
|
||||||
bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").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 {
|
bool PolicyHelper::isExpired() const {
|
||||||
if (!isValid()) return true;
|
if (!isValid()) return true;
|
||||||
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
|
const QDateTime expiry = QDateTime::fromString(m_policy.value("valid_until").toString(), Qt::ISODate);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public:
|
|||||||
bool forceUpdate() const;
|
bool forceUpdate() const;
|
||||||
bool allowRollback() const;
|
bool allowRollback() const;
|
||||||
bool isOfflineAllowed() const;
|
bool isOfflineAllowed() const;
|
||||||
|
bool gitTagsEnabled() const;
|
||||||
bool isExpired() const;
|
bool isExpired() const;
|
||||||
qint64 policySeq() const;
|
qint64 policySeq() const;
|
||||||
QString message() const;
|
QString message() const;
|
||||||
|
|||||||
+104
-14
@@ -8,6 +8,7 @@
|
|||||||
#include <QMessageAuthenticationCode>
|
#include <QMessageAuthenticationCode>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QStringList>
|
||||||
#include <QUuid>
|
#include <QUuid>
|
||||||
|
|
||||||
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
|
||||||
@@ -20,8 +21,20 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
|||||||
const QString& version, const QString& secret,
|
const QString& version, const QString& secret,
|
||||||
QString* ticketPath, QString* errorMessage)
|
QString* ticketPath, QString* errorMessage)
|
||||||
{
|
{
|
||||||
if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
|
// Launcher 启动业务主程序前生成一次性 ticket。
|
||||||
if (errorMessage) *errorMessage = "ticket identity or secret is empty";
|
// 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(", ")));
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||||
@@ -34,12 +47,25 @@ bool TicketHelper::createTicket(const QString& appId, const QString& deviceId,
|
|||||||
};
|
};
|
||||||
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
|
QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}};
|
||||||
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
|
const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets");
|
||||||
if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; }
|
if (!QDir().mkpath(dirPath)) {
|
||||||
|
if (errorMessage) {
|
||||||
|
*errorMessage = QCoreApplication::translate(
|
||||||
|
"TicketHelper",
|
||||||
|
"Cannot create launch ticket directory: %1.")
|
||||||
|
.arg(dirPath);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
|
const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json");
|
||||||
QSaveFile file(path);
|
QSaveFile file(path);
|
||||||
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
|
||||||
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) {
|
||||||
if (errorMessage) *errorMessage = "cannot save ticket";
|
if (errorMessage) {
|
||||||
|
*errorMessage = QCoreApplication::translate(
|
||||||
|
"TicketHelper",
|
||||||
|
"Cannot save launch ticket file: %1. Error: %2.")
|
||||||
|
.arg(path, file.errorString());
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||||
@@ -51,24 +77,42 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
|||||||
const QString& expectedDeviceId, const QString& expectedVersion,
|
const QString& expectedDeviceId, const QString& expectedVersion,
|
||||||
const QString& secret, QString* errorMessage)
|
const QString& secret, QString* errorMessage)
|
||||||
{
|
{
|
||||||
|
// 主程序启动后第一时间把 ticket 改名成 .consuming,再读取并删除。
|
||||||
|
// 这样同一张 ticket 即使校验失败也不能被重复使用,避免重放启动。
|
||||||
const QString consumingPath = ticketPath + ".consuming."
|
const QString consumingPath = ticketPath + ".consuming."
|
||||||
+ QString::number(QCoreApplication::applicationPid());
|
+ QString::number(QCoreApplication::applicationPid());
|
||||||
if (!QFile::rename(ticketPath, consumingPath)) {
|
if (!QFile::rename(ticketPath, consumingPath)) {
|
||||||
if (errorMessage) *errorMessage = "ticket missing or already consumed";
|
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);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
QFile file(consumingPath);
|
QFile file(consumingPath);
|
||||||
if (!file.open(QIODevice::ReadOnly)) {
|
if (!file.open(QIODevice::ReadOnly)) {
|
||||||
QFile::remove(consumingPath);
|
QFile::remove(consumingPath);
|
||||||
if (errorMessage) *errorMessage = "cannot read claimed ticket";
|
if (errorMessage) {
|
||||||
|
*errorMessage = QCoreApplication::translate(
|
||||||
|
"TicketHelper",
|
||||||
|
"Cannot read claimed launch ticket: %1. Error: %2.")
|
||||||
|
.arg(consumingPath, file.errorString());
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const QByteArray raw = file.readAll(); file.close();
|
const QByteArray raw = file.readAll(); file.close();
|
||||||
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。
|
QFile::remove(consumingPath); // Consume once; it must not be replayed regardless of success or failure.
|
||||||
QJsonParseError parseError;
|
QJsonParseError parseError;
|
||||||
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError);
|
||||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||||
if (errorMessage) *errorMessage = "invalid ticket JSON"; return false;
|
if (errorMessage) {
|
||||||
|
*errorMessage = QCoreApplication::translate(
|
||||||
|
"TicketHelper",
|
||||||
|
"Launch ticket is not valid JSON. JSON error: %1.")
|
||||||
|
.arg(parseError.errorString());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
const QJsonObject wrapper = doc.object();
|
const QJsonObject wrapper = doc.object();
|
||||||
const QJsonObject payload = wrapper.value("payload").toObject();
|
const QJsonObject payload = wrapper.value("payload").toObject();
|
||||||
@@ -77,14 +121,60 @@ bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& ex
|
|||||||
const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate);
|
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 expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate);
|
||||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
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)
|
const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5)
|
||||||
&& expires >= now && issued.secsTo(expires) <= 65;
|
&& expires >= now && issued.secsTo(expires) <= 65;
|
||||||
if (actual.isEmpty() || actual != expected || !identityOk || !timeOk
|
if (actual.isEmpty() || actual != expected) {
|
||||||
|| payload.value("nonce").toString().isEmpty()) {
|
if (errorMessage) {
|
||||||
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
|
*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.");
|
||||||
|
}
|
||||||
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#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
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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。
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
# 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` 是否指向真实文件。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 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`。
|
||||||
@@ -8,14 +8,14 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON)
|
|||||||
set(CMAKE_AUTOMOC ON)
|
set(CMAKE_AUTOMOC ON)
|
||||||
set(CMAKE_AUTOUIC ON)
|
set(CMAKE_AUTOUIC ON)
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
# 依赖Qt模块:网络、基础核心、窗口
|
|
||||||
set(CMAKE_PREFIX_PATH "C:\\Qt\\5.15.2\\msvc2019_64" ${CMAKE_PREFIX_PATH})
|
|
||||||
find_package(Qt5 REQUIRED COMPONENTS Core Network Gui Widgets)
|
|
||||||
# 所有源码文件
|
# 所有源码文件
|
||||||
set(SRC_LIST
|
set(SRC_LIST
|
||||||
main.cpp
|
main.cpp
|
||||||
UpdateLogic.h
|
UpdateLogic.h
|
||||||
UpdateLogic.cpp
|
UpdateLogic.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||||
|
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||||
)
|
)
|
||||||
# 生成可执行程序
|
# 生成可执行程序
|
||||||
add_executable(Launcher ${SRC_LIST})
|
add_executable(Launcher ${SRC_LIST})
|
||||||
@@ -28,10 +28,6 @@ target_include_directories(Launcher PRIVATE ${OPENSSL_INC})
|
|||||||
|
|
||||||
# 链接Common,自动带上OpenSSL库
|
# 链接Common,自动带上OpenSSL库
|
||||||
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
|
target_link_libraries(Launcher Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets)
|
||||||
# 输出编译产物到 out 文件夹(干净不乱)
|
|
||||||
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
|
|
||||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
|
||||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
|
|
||||||
# 强制VS打开CMake文件夹时默认选中该可执行目标
|
# 强制VS打开CMake文件夹时默认选中该可执行目标
|
||||||
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
set_property(DIRECTORY ${CMAKE_SOURCE_DIR} PROPERTY VS_STARTUP_PROJECT Launcher)
|
||||||
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
|
# 编译完成自动执行windeployqt,复制Qt dll到exe目录
|
||||||
|
|||||||
+241
-71
@@ -1,117 +1,287 @@
|
|||||||
#include "UpdateLogic.h"
|
#include "UpdateLogic.h"
|
||||||
|
|
||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QApplication>
|
#include <QJsonDocument>
|
||||||
#include "PolicyHelper.h"
|
#include <QSaveFile>
|
||||||
#include "LocalStateHelper.h"
|
#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
|
||||||
|
|
||||||
UpdateLogic::UpdateLogic(QObject* parent)
|
UpdateLogic::UpdateLogic(QObject* parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
{
|
{
|
||||||
ConfigHelper& cfg = ConfigHelper::instance();
|
ConfigHelper& cfg = ConfigHelper::instance();
|
||||||
m_serverAddr = cfg.getValue("Server", "api_base_url");
|
m_serverAddr = trimBaseUrl(cfg.getValue("Server", "api_base_url"));
|
||||||
m_appId = cfg.getValue("App", "app_id");
|
m_appId = cfg.getValue("App", "product_code").trimmed();
|
||||||
m_curVer = cfg.getValue("App", "current_version");
|
if (m_appId.isEmpty())
|
||||||
m_channel = cfg.getValue("App", "channel");
|
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");
|
||||||
|
|
||||||
// Debug print config
|
|
||||||
qDebug() << "Read server addr:" << m_serverAddr;
|
qDebug() << "Read server addr:" << m_serverAddr;
|
||||||
qDebug() << "Read app id:" << m_appId;
|
qDebug() << "Read product code:" << m_appId;
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateLogic::checkUpdate()
|
void UpdateLogic::checkUpdate()
|
||||||
{
|
{
|
||||||
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty())
|
m_error.clear();
|
||||||
{
|
|
||||||
qDebug() << "Config incomplete, abort update check";
|
|
||||||
m_needUpdate = false;
|
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())
|
||||||
|
{
|
||||||
|
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;
|
||||||
return;
|
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);
|
|
||||||
|
|
||||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
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)
|
||||||
{
|
{
|
||||||
qDebug() << "Check update HTTP code:" << code;
|
qDebug() << "Check update HTTP code:" << code;
|
||||||
m_lastStatusCode = code;
|
m_lastStatusCode = code;
|
||||||
m_checkResp = resp;
|
m_checkResp = resp;
|
||||||
m_networkOk = (code == 200);
|
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;
|
m_needUpdate = false;
|
||||||
|
m_error = serverMessage(resp);
|
||||||
|
qDebug() << "Check update api failed:" << m_error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.updateOnlineVerified(onlinePolicy.policySeq());
|
|
||||||
if (!state.saveState())
|
const QJsonArray releases = resp.value(QStringLiteral("data")).toArray();
|
||||||
{
|
QJsonObject selected;
|
||||||
qDebug() << "Cannot persist online policy state";
|
for (const QJsonValue& value : releases) {
|
||||||
m_networkOk = false;
|
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;
|
m_needUpdate = false;
|
||||||
|
if (!releases.isEmpty())
|
||||||
|
m_latestVer = releases.first().toObject().value(QStringLiteral("version")).toString();
|
||||||
|
qDebug() << "No entitled downloadable update for current client.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_needUpdate = resp["need_update"].toBool();
|
|
||||||
m_latestVer = resp["latest_version"].toString();
|
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() << "Need update:" << m_needUpdate;
|
||||||
qDebug() << "Latest version:" << m_latestVer;
|
qDebug() << "Latest version:" << m_latestVer;
|
||||||
qDebug() << "Policy seq:" << onlinePolicy.policySeq();
|
qDebug() << "Release id:" << selected.value(QStringLiteral("id")).toString();
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
m_needUpdate = false;
|
|
||||||
qDebug() << "Check update api failed";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
|
void UpdateLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId)
|
||||||
{
|
{
|
||||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
Q_UNUSED(appId);
|
||||||
QJsonObject body;
|
Q_UNUSED(channel);
|
||||||
body["app_id"] = appId;
|
Q_UNUSED(ver);
|
||||||
body["channel"] = channel;
|
Q_UNUSED(verId);
|
||||||
body["version"] = ver;
|
qDebug() << "SimCAE Hub manifest already contains authorized package download URLs.";
|
||||||
body["version_id"] = verId;
|
}
|
||||||
QJsonArray files;
|
|
||||||
body["files"] = files;
|
|
||||||
|
|
||||||
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
|
bool UpdateLogic::cacheManifest(const QString& appId, const QString& channel, const QString& version,
|
||||||
|
int versionId, const QString& cacheDir)
|
||||||
{
|
{
|
||||||
qDebug() << "\n========== File Download Url ==========";
|
Q_UNUSED(versionId);
|
||||||
qDebug() << resp["files"].toArray();
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)
|
void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success)
|
||||||
{
|
{
|
||||||
QString url = m_serverAddr + "/api/v1/update/report";
|
Q_UNUSED(deviceId);
|
||||||
QJsonObject body;
|
Q_UNUSED(fromVer);
|
||||||
body["app_id"] = m_appId;
|
Q_UNUSED(toVer);
|
||||||
body["device_id"] = deviceId;
|
Q_UNUSED(success);
|
||||||
body["from_version"] = fromVer;
|
qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
|
||||||
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,14 +13,19 @@ public:
|
|||||||
void checkUpdate();
|
void checkUpdate();
|
||||||
void getDownloadUrl(const QString& appId, const QString& channel, const QString& ver, int verId);
|
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);
|
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; }
|
bool getNeedUpdate() const { return m_needUpdate; }
|
||||||
QString getLatestVersion() const { return m_latestVer; }
|
QString getLatestVersion() const { return m_latestVer; }
|
||||||
QJsonObject getCheckResult() const { return m_checkResp; }
|
QJsonObject getCheckResult() const { return m_checkResp; }
|
||||||
bool isNetworkOk() const { return m_networkOk; }
|
bool isNetworkOk() const { return m_networkOk; }
|
||||||
int lastStatusCode() const { return m_lastStatusCode; }
|
int lastStatusCode() const { return m_lastStatusCode; }
|
||||||
|
QString errorString() const { return m_error; }
|
||||||
|
|
||||||
QString getAppId() const { return m_appId; }
|
QString getAppId() const { return m_appId; }
|
||||||
|
QString getProductCode() const { return m_appId; }
|
||||||
QString getChannel() const { return m_channel; }
|
QString getChannel() const { return m_channel; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
@@ -35,4 +40,5 @@ private:
|
|||||||
int m_lastStatusCode = 0;
|
int m_lastStatusCode = 0;
|
||||||
QString m_latestVer;
|
QString m_latestVer;
|
||||||
QJsonObject m_checkResp;
|
QJsonObject m_checkResp;
|
||||||
|
QString m_error;
|
||||||
};
|
};
|
||||||
+163
-209
@@ -1,31 +1,83 @@
|
|||||||
#include <windows.h>
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QProgressDialog>
|
#include <QProgressDialog>
|
||||||
#include <QInputDialog>
|
#include <QTranslator>
|
||||||
#include <QLineEdit>
|
#include <QUuid>
|
||||||
#include <QTextCodec>
|
|
||||||
#include "UpdateLogic.h"
|
#include "UpdateLogic.h"
|
||||||
#include "../Common/ConfigHelper.h"
|
#include "../Common/ConfigHelper.h"
|
||||||
#include "../Common/PolicyHelper.h"
|
|
||||||
#include "../Common/LocalStateHelper.h"
|
|
||||||
#include "../Common/TicketHelper.h"
|
#include "../Common/TicketHelper.h"
|
||||||
#include "../Common/DeviceIdentityHelper.h"
|
|
||||||
#include <QFile>
|
namespace {
|
||||||
#include <QFileDialog>
|
|
||||||
#include <QDir>
|
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[])
|
||||||
{
|
{
|
||||||
SetConsoleOutputCP(65001);
|
|
||||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
|
||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
QApplication::setApplicationName("Marsco Launcher");
|
QApplication::setApplicationName("SimCAE Launcher");
|
||||||
|
QTranslator translator;
|
||||||
|
if (translator.load(QStringLiteral(":/i18n/update-client_zh_CN.qm")))
|
||||||
|
app.installTranslator(&translator);
|
||||||
|
|
||||||
QProgressDialog progress("正在检查软件更新...", QString(), 0, 0);
|
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
|
||||||
progress.setWindowTitle("Marsco 软件启动器");
|
if (elevatedWriteExitCode >= 0)
|
||||||
|
return elevatedWriteExitCode;
|
||||||
|
|
||||||
|
QProgressDialog progress(QCoreApplication::translate("Launcher", "Checking for software updates..."), QString(), 0, 0);
|
||||||
|
progress.setWindowTitle(QCoreApplication::translate("Launcher", "SimCAE Launcher"));
|
||||||
progress.setCancelButton(nullptr);
|
progress.setCancelButton(nullptr);
|
||||||
progress.setWindowModality(Qt::ApplicationModal);
|
progress.setWindowModality(Qt::ApplicationModal);
|
||||||
progress.setMinimumDuration(0);
|
progress.setMinimumDuration(0);
|
||||||
@@ -33,83 +85,11 @@ int main(int argc, char* argv[])
|
|||||||
progress.show();
|
progress.show();
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
|
|
||||||
const QString appDir = QApplication::applicationDirPath();
|
|
||||||
ConfigHelper& config = ConfigHelper::instance();
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
const auto isLicenseError = [](const QString& errorText) {
|
const QString appDir = QApplication::applicationDirPath();
|
||||||
const QString text = errorText.toLower();
|
|
||||||
return text.contains("license")
|
progress.setLabelText(QCoreApplication::translate("Launcher", "Checking authorized updates..."));
|
||||||
|| errorText.contains("授权")
|
|
||||||
|| errorText.contains("过期")
|
|
||||||
|| errorText.contains("设备数量已达到上限")
|
|
||||||
|| errorText.contains("已绑定其他授权");
|
|
||||||
};
|
|
||||||
const auto clearDeviceCredential = [&]() {
|
|
||||||
QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
|
|
||||||
config.setValue("Update", "device_id", QString());
|
|
||||||
};
|
|
||||||
QString licenseKey = config.getValue("License", "license_key").trimmed();
|
|
||||||
auto promptAndSaveLicense = [&](const QString& reason) -> QString {
|
|
||||||
QString promptReason = reason;
|
|
||||||
while (true) {
|
|
||||||
progress.close();
|
|
||||||
bool accepted = false;
|
|
||||||
const QString appName = config.getValue("App", "app_name").trimmed();
|
|
||||||
const QString prompt = promptReason.trimmed().isEmpty()
|
|
||||||
? QString("请输入%1授权 License:").arg(appName.isEmpty() ? "软件" : appName)
|
|
||||||
: QString("%1\n\n请重新输入%2授权 License:").arg(promptReason, appName.isEmpty() ? "软件" : appName);
|
|
||||||
licenseKey = QInputDialog::getText(
|
|
||||||
nullptr,
|
|
||||||
"输入 License",
|
|
||||||
prompt,
|
|
||||||
QLineEdit::Normal,
|
|
||||||
QString(),
|
|
||||||
&accepted
|
|
||||||
).trimmed();
|
|
||||||
if (!accepted) {
|
|
||||||
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。");
|
|
||||||
return QString();
|
|
||||||
}
|
|
||||||
if (licenseKey.isEmpty()) {
|
|
||||||
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。");
|
|
||||||
promptReason.clear();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!config.setValue("License", "license_key", licenseKey)) {
|
|
||||||
QMessageBox::critical(nullptr, "保存 License 失败",
|
|
||||||
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError()));
|
|
||||||
return QString();
|
|
||||||
}
|
|
||||||
progress.show();
|
|
||||||
progress.setLabelText("正在验证 License...");
|
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
return licenseKey;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
if (licenseKey.isEmpty()) {
|
|
||||||
licenseKey = promptAndSaveLicense(QString());
|
|
||||||
if (licenseKey.isEmpty()) return 0;
|
|
||||||
}
|
|
||||||
DeviceIdentityHelper identity(appDir);
|
|
||||||
if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
|
|
||||||
config.getValue("Server", "client_token"),
|
|
||||||
config.getValue("App", "app_id"),
|
|
||||||
config.getValue("App", "channel"),
|
|
||||||
licenseKey)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const QString error = identity.errorString();
|
|
||||||
if (!isLicenseError(error)) {
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr, "设备身份验证失败", error);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
clearDeviceCredential();
|
|
||||||
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error));
|
|
||||||
if (licenseKey.isEmpty()) return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateLogic logic;
|
UpdateLogic logic;
|
||||||
logic.checkUpdate();
|
logic.checkUpdate();
|
||||||
|
|
||||||
@@ -117,164 +97,138 @@ int main(int argc, char* argv[])
|
|||||||
const bool networkOk = logic.isNetworkOk();
|
const bool networkOk = logic.isNetworkOk();
|
||||||
const QString latestVer = logic.getLatestVersion();
|
const QString latestVer = logic.getLatestVersion();
|
||||||
const QJsonObject response = logic.getCheckResult();
|
const QJsonObject response = logic.getCheckResult();
|
||||||
const int targetVersionId = response.value("version_id").toInt();
|
const QString releaseId = response.value(QStringLiteral("release_id")).toString(
|
||||||
const QString appId = logic.getAppId();
|
response.value(QStringLiteral("id")).toString());
|
||||||
|
const QString appId = logic.getProductCode();
|
||||||
const QString channel = logic.getChannel();
|
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();
|
||||||
|
|
||||||
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() ? "设备或 License 授权无效。" : message;
|
|
||||||
if (isLicenseError(displayMessage)) {
|
|
||||||
clearDeviceCredential();
|
|
||||||
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage));
|
|
||||||
if (!newLicense.isEmpty()) {
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。");
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString launchToken = config.getValue("App", "launch_token");
|
|
||||||
const QString currentVersion = config.getValue("App", "current_version");
|
|
||||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||||
const QString value = config.getValue("Runtime", key).trimmed();
|
return ConfigHelper::executableNameForCurrentPlatform(
|
||||||
return value.isEmpty() ? fallback : value;
|
config.getValue(QStringLiteral("Runtime"), key), fallback);
|
||||||
};
|
};
|
||||||
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
|
const QString mainExecutable = configuredName(QStringLiteral("main_executable"), QStringLiteral("MainApp"));
|
||||||
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
|
const QString updaterExecutable = configuredName(QStringLiteral("updater_executable"), QStringLiteral("Updater"));
|
||||||
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
|
const QString mainAppPath = QDir(appDir).filePath(mainExecutable);
|
||||||
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
|
const QString updaterPath = QDir(appDir).filePath(updaterExecutable);
|
||||||
const auto importOfflinePackage = [&]() {
|
|
||||||
const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.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, "离线更新", "未选择离线更新包。");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
QString mainStartupError;
|
||||||
const auto startMainApp = [&]() {
|
const auto startMainApp = [&]() {
|
||||||
QString ticketPath;
|
mainStartupError.clear();
|
||||||
QString ticketError;
|
if (!QFileInfo::exists(mainAppPath)) {
|
||||||
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
|
mainStartupError = QCoreApplication::translate(
|
||||||
launchToken, &ticketPath, &ticketError)) {
|
"Launcher",
|
||||||
qDebug() << "Cannot create launch ticket:" << ticketError;
|
"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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString ticketPath;
|
||||||
|
QString ticketError;
|
||||||
|
if (!TicketHelper::createTicket(appId, 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,
|
const bool started = QProcess::startDetached(mainAppPath,
|
||||||
QStringList{QString("--ticket-file=%1").arg(ticketPath)});
|
QStringList{QStringLiteral("--ticket-file=%1").arg(ticketPath)});
|
||||||
if (!started) QFile::remove(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;
|
return started;
|
||||||
};
|
};
|
||||||
|
|
||||||
progress.setLabelText("正在验证本地运行策略...");
|
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
|
||||||
QApplication::processEvents();
|
|
||||||
|
|
||||||
PolicyHelper policy(appDir);
|
|
||||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
|
||||||
{
|
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString()));
|
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.")));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
} else if (!networkOk) {
|
||||||
if (policy.isExpired())
|
|
||||||
{
|
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。");
|
QMessageBox::warning(nullptr,
|
||||||
return -1;
|
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."));
|
||||||
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
|
progress.show();
|
||||||
{
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr, "当前版本不可运行",
|
|
||||||
policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion)
|
|
||||||
: policy.message());
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalStateHelper state(appDir);
|
|
||||||
if (!state.loadState())
|
|
||||||
{
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString()));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (state.isPolicySeqRolledBack(policy.policySeq()))
|
|
||||||
{
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (state.isSystemTimeRewound())
|
|
||||||
{
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。");
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (networkOk && needUpdate)
|
if (networkOk && needUpdate)
|
||||||
{
|
{
|
||||||
progress.close();
|
progress.close();
|
||||||
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
|
const QString prompt = QCoreApplication::translate(
|
||||||
const QString dialogTitle = rollbackOperation ? "版本回退"
|
"Launcher",
|
||||||
: (policy.forceUpdate() ? "必须更新" : "发现新版本");
|
"Version %1 is available. Update now?").arg(latestVer);
|
||||||
const QString prompt = policy.message().isEmpty()
|
const bool accepted = QMessageBox::question(nullptr,
|
||||||
? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?"
|
QCoreApplication::translate("Launcher", "New Version Available"),
|
||||||
: "发现新版本 %1,是否现在更新?").arg(latestVer)
|
prompt,
|
||||||
: policy.message() + QString("\n目标版本:%1").arg(latestVer);
|
QMessageBox::Yes | QMessageBox::No,
|
||||||
bool accepted = true;
|
QMessageBox::No) == QMessageBox::Yes;
|
||||||
if (policy.forceUpdate()) {
|
|
||||||
QMessageBox::information(nullptr, dialogTitle, prompt);
|
|
||||||
} else {
|
|
||||||
accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
|
|
||||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes;
|
|
||||||
}
|
|
||||||
if (!accepted) {
|
if (!accepted) {
|
||||||
if (!startMainApp()) {
|
if (!startMainApp()) {
|
||||||
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
QMessageBox::critical(nullptr,
|
||||||
|
QCoreApplication::translate("Launcher", "Startup Failed"),
|
||||||
|
mainStartupError.isEmpty()
|
||||||
|
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
|
||||||
|
: mainStartupError);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
if (!QProcess::startDetached(updaterPath, updaterArgs))
|
if (!QProcess::startDetached(updaterPath, updaterArgs))
|
||||||
{
|
{
|
||||||
QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath));
|
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(" "))));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!networkOk) {
|
progress.setLabelText(networkOk
|
||||||
progress.close();
|
? QCoreApplication::translate("Launcher", "The application is up to date. Starting...")
|
||||||
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?",
|
: QCoreApplication::translate("Launcher", "Offline startup. Starting..."));
|
||||||
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
|
|
||||||
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。");
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
progress.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!networkOk && !policy.isOfflineAllowed())
|
|
||||||
{
|
|
||||||
progress.close();
|
|
||||||
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动...");
|
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
if (!startMainApp())
|
if (!startMainApp())
|
||||||
{
|
{
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
|
QMessageBox::critical(nullptr,
|
||||||
|
QCoreApplication::translate("Launcher", "Startup Failed"),
|
||||||
|
mainStartupError.isEmpty()
|
||||||
|
? QCoreApplication::translate("Launcher", "Cannot start the main application: %1").arg(mainAppPath)
|
||||||
|
: mainStartupError);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
progress.close();
|
progress.close();
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ add_executable(MainApp
|
|||||||
MainWindow.h
|
MainWindow.h
|
||||||
MainWindow.cpp
|
MainWindow.cpp
|
||||||
../Common/ConfigHelper.h
|
../Common/ConfigHelper.h
|
||||||
|
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||||
|
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||||
)
|
)
|
||||||
target_include_directories(MainApp
|
target_include_directories(MainApp
|
||||||
PUBLIC ${CMAKE_SOURCE_DIR}/Common
|
PUBLIC ${CMAKE_SOURCE_DIR}/Common
|
||||||
|
|||||||
+38
-41
@@ -1,69 +1,66 @@
|
|||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QFont>
|
#include <QCryptographicHash>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFont>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QCryptographicHash>
|
|
||||||
#include "../Common/PolicyHelper.h"
|
|
||||||
#include "../Common/ConfigHelper.h"
|
#include "../Common/ConfigHelper.h"
|
||||||
|
|
||||||
static QString readDllVersion(const QString& dllPath)
|
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)
|
||||||
{
|
{
|
||||||
QFile file(dllPath);
|
QFile file(dllPath);
|
||||||
if (!file.exists())
|
if (!file.exists())
|
||||||
return "missing";
|
return QStringLiteral("missing");
|
||||||
|
|
||||||
if (!file.open(QIODevice::ReadOnly))
|
if (!file.open(QIODevice::ReadOnly))
|
||||||
return "unreadable";
|
return QStringLiteral("unreadable");
|
||||||
|
|
||||||
QByteArray data = file.read(1024);
|
const QByteArray data = file.read(1024);
|
||||||
file.close();
|
file.close();
|
||||||
return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8));
|
return QString::fromUtf8(QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex().left(8));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
MainWindow::MainWindow(QWidget* parent)
|
MainWindow::MainWindow(QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
{
|
{
|
||||||
this->setWindowTitle("Marsco Demo MainApp");
|
this->setWindowTitle("SimCAE Demo MainApp");
|
||||||
this->resize(600, 400);
|
this->resize(600, 400);
|
||||||
|
|
||||||
QString appVersion = ConfigHelper::instance().getValue("App", "current_version");
|
const QString appVersion = configValue(QStringLiteral("current_version"), QStringLiteral("unknown"));
|
||||||
if (appVersion.isEmpty())
|
const QString product = configValue(QStringLiteral("product_code"),
|
||||||
appVersion = "unknown";
|
configValue(QStringLiteral("app_id"), QStringLiteral("unknown")));
|
||||||
|
const QString channel = configValue(QStringLiteral("channel"), QStringLiteral("stable"));
|
||||||
QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
|
const QString apiBaseUrl = configValue(QStringLiteral("api_base_url"), QStringLiteral("not configured"));
|
||||||
|
const QString tokenState = configValue(QStringLiteral("client_token")).isEmpty()
|
||||||
PolicyHelper policy(QApplication::applicationDirPath());
|
? QStringLiteral("missing")
|
||||||
QString policyResult;
|
: QStringLiteral("configured");
|
||||||
if (!policy.loadPolicy("config/version_policy.dat"))
|
const QString dllVersion = readDllVersion(QApplication::applicationDirPath() + "/plugins/demo_plugin.dll");
|
||||||
{
|
|
||||||
policyResult = "policy missing";
|
|
||||||
}
|
|
||||||
else if (!policy.isValid())
|
|
||||||
{
|
|
||||||
policyResult = "policy invalid";
|
|
||||||
}
|
|
||||||
else if (!policy.isVersionAllowed(appVersion))
|
|
||||||
{
|
|
||||||
policyResult = "version disabled";
|
|
||||||
}
|
|
||||||
else if (policy.isExpired())
|
|
||||||
{
|
|
||||||
policyResult = "policy expired";
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
policyResult = "policy ok";
|
|
||||||
}
|
|
||||||
|
|
||||||
QVBoxLayout* layout = new QVBoxLayout(this);
|
QVBoxLayout* layout = new QVBoxLayout(this);
|
||||||
QLabel* label = new QLabel(QString("Software Running Successfully\nVersion: %1\nDLL Version: %2\nPolicy: %3")
|
QLabel* label = new QLabel(QString(
|
||||||
.arg(appVersion)
|
"Software Running Successfully\n"
|
||||||
.arg(dllVersion)
|
"Product: %1\n"
|
||||||
.arg(policyResult));
|
"Version: %2\n"
|
||||||
|
"Channel: %3\n"
|
||||||
|
"Server: %4\n"
|
||||||
|
"Update Client Token: %5\n"
|
||||||
|
"DLL Version: %6")
|
||||||
|
.arg(product, appVersion, channel, apiBaseUrl, tokenState, dllVersion));
|
||||||
QFont font = label->font();
|
QFont font = label->font();
|
||||||
font.setPointSize(14);
|
font.setPointSize(13);
|
||||||
label->setFont(font);
|
label->setFont(font);
|
||||||
label->setAlignment(Qt::AlignCenter);
|
label->setAlignment(Qt::AlignCenter);
|
||||||
|
|
||||||
|
|||||||
+54
-69
@@ -1,47 +1,75 @@
|
|||||||
#include <Windows.h>
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QTextCodec>
|
|
||||||
#include <QMessageBox>
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
|
#include <QTranslator>
|
||||||
|
|
||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
#include "../Common/ConfigHelper.h"
|
#include "../Common/ConfigHelper.h"
|
||||||
#include "../Common/PolicyHelper.h"
|
|
||||||
#include "../Common/LocalStateHelper.h"
|
|
||||||
#include "../Common/TicketHelper.h"
|
|
||||||
#include "../Common/IntegrityHelper.h"
|
#include "../Common/IntegrityHelper.h"
|
||||||
#include "../Common/DeviceIdentityHelper.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
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
SetConsoleOutputCP(936);
|
|
||||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK"));
|
|
||||||
|
|
||||||
QApplication a(argc, 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;
|
||||||
|
|
||||||
qDebug() << Qt::endl << "entered main app" << Qt::endl;
|
qDebug() << Qt::endl << "entered main app" << Qt::endl;
|
||||||
|
|
||||||
ConfigHelper& config = ConfigHelper::instance();
|
ConfigHelper& config = ConfigHelper::instance();
|
||||||
QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed();
|
QString launcherExecutable = config.getValue(QStringLiteral("Runtime"), QStringLiteral("launcher_executable")).trimmed();
|
||||||
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
|
launcherExecutable = ConfigHelper::executableNameForCurrentPlatform(launcherExecutable, QStringLiteral("Launcher"));
|
||||||
|
|
||||||
QString ticketFilePath;
|
QString ticketFilePath;
|
||||||
QString healthFilePath;
|
QString healthFilePath;
|
||||||
for (int i = 1; i < argc; ++i)
|
for (int i = 1; i < argc; ++i)
|
||||||
{
|
{
|
||||||
const QString arg(argv[i]);
|
const QString arg(argv[i]);
|
||||||
if (arg.startsWith("--ticket-file="))
|
if (arg.startsWith(QStringLiteral("--ticket-file=")))
|
||||||
ticketFilePath = arg.mid(QString("--ticket-file=").size());
|
ticketFilePath = arg.mid(QStringLiteral("--ticket-file=").size());
|
||||||
else if (arg.startsWith("--health-file="))
|
else if (arg.startsWith(QStringLiteral("--health-file=")))
|
||||||
healthFilePath = arg.mid(QString("--health-file=").size());
|
healthFilePath = arg.mid(QStringLiteral("--health-file=").size());
|
||||||
}
|
}
|
||||||
|
|
||||||
QString ticketError;
|
QString ticketError;
|
||||||
if (ticketFilePath.isEmpty()
|
if (ticketFilePath.isEmpty()
|
||||||
|| !TicketHelper::consumeAndVerify(ticketFilePath,
|
|| !TicketHelper::consumeAndVerify(ticketFilePath,
|
||||||
config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
|
productCode(), config.getValue(QStringLiteral("Update"), QStringLiteral("device_id")),
|
||||||
config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
|
config.getValue(QStringLiteral("App"), QStringLiteral("current_version")),
|
||||||
|
config.getValue(QStringLiteral("App"), QStringLiteral("launch_token")),
|
||||||
&ticketError))
|
&ticketError))
|
||||||
{
|
{
|
||||||
QMessageBox::critical(nullptr, "Startup Restriction",
|
QMessageBox::critical(nullptr, "Startup Restriction",
|
||||||
@@ -50,65 +78,19 @@ int main(int argc, char* argv[])
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString appDir = QApplication::applicationDirPath();
|
|
||||||
const QString installRoot = config.installRoot();
|
const QString installRoot = config.installRoot();
|
||||||
DeviceIdentityHelper identity(appDir);
|
if (configFlag(QStringLiteral("verify_installed_on_start"))) {
|
||||||
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel")))
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
PolicyHelper policy(appDir);
|
|
||||||
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (policy.isExpired())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalStateHelper state(appDir);
|
|
||||||
if (!state.loadState())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isPolicySeqRolledBack(policy.policySeq()))
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.isSystemTimeRewound())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
IntegrityHelper integrity(installRoot);
|
IntegrityHelper integrity(installRoot);
|
||||||
if (!integrity.verifyInstalledVersion(
|
if (!integrity.verifyInstalledVersion(
|
||||||
config.getValue("App", "app_id"), config.getValue("App", "channel"),
|
productCode(),
|
||||||
config.getValue("App", "current_version")))
|
config.getValue(QStringLiteral("App"), QStringLiteral("channel")),
|
||||||
|
config.getValue(QStringLiteral("App"), QStringLiteral("current_version"))))
|
||||||
{
|
{
|
||||||
QMessageBox::critical(nullptr, "Integrity Check Failed",
|
QMessageBox::critical(nullptr, "Integrity Check Failed",
|
||||||
QString("Application files failed signed Manifest verification:\n%1")
|
QString("Startup blocked because the installed files failed local Manifest verification.\n\nDetails:\n%1")
|
||||||
.arg(integrity.errorString()));
|
.arg(integrity.errorString()));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
MainWindow w;
|
|
||||||
w.show();
|
|
||||||
|
|
||||||
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
|
|
||||||
if (!state.saveState())
|
|
||||||
{
|
|
||||||
QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString()));
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!healthFilePath.isEmpty())
|
if (!healthFilePath.isEmpty())
|
||||||
@@ -125,6 +107,9 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MainWindow w;
|
||||||
|
w.show();
|
||||||
|
|
||||||
qDebug() << "Main program MainApp is running normally";
|
qDebug() << "Main program MainApp is running normally";
|
||||||
return a.exec();
|
return a.exec();
|
||||||
}
|
}
|
||||||
|
|||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
客户端配置说明
|
|
||||||
==============
|
|
||||||
|
|
||||||
统一从程序目录下的 config/app_config.json 读取配置。
|
|
||||||
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
|
|
||||||
|
|
||||||
接入新软件时通常需要修改:
|
|
||||||
|
|
||||||
1. app_id、app_name、channel、current_version。
|
|
||||||
2. api_base_url、client_token、license_key、launch_token。
|
|
||||||
3. main_executable:团队业务主程序文件名。
|
|
||||||
4. launcher_executable、updater_executable、bootstrap_executable。
|
|
||||||
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
|
|
||||||
|
|
||||||
完整格式参考 config/app_config.example.json。
|
|
||||||
运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。
|
|
||||||
|
|
||||||
Windows 发布打包:
|
|
||||||
|
|
||||||
1. 使用 Release 配置编译全部客户端程序。
|
|
||||||
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
|
|
||||||
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
|
|
||||||
4. 在 PowerShell 执行:
|
|
||||||
powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json
|
|
||||||
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
|
|
||||||
|
|
||||||
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
|
|
||||||
-188
@@ -1,188 +0,0 @@
|
|||||||
# UpdateClientSDK 接入说明
|
|
||||||
|
|
||||||
这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。
|
|
||||||
|
|
||||||
SDK 核心程序:
|
|
||||||
|
|
||||||
- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。
|
|
||||||
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
|
|
||||||
- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。
|
|
||||||
- `config/app_config.json`:接入方配置。
|
|
||||||
- `config/manifest_public_key.pem`:验证服务端签名用的公钥。
|
|
||||||
|
|
||||||
## 接入方需要做什么
|
|
||||||
|
|
||||||
假设接入的软件叫 `YourApp.exe`。
|
|
||||||
|
|
||||||
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。
|
|
||||||
2. 创建或确认渠道,例如 `stable`。
|
|
||||||
3. 创建 License,把生成的 `license_key` 填到客户端配置。
|
|
||||||
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`。
|
|
||||||
5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。
|
|
||||||
6. 把 `config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
|
|
||||||
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
|
|
||||||
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
|
|
||||||
|
|
||||||
## 安装目录写权限要求
|
|
||||||
|
|
||||||
当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
config/app_config.json
|
|
||||||
config/client_identity.dat
|
|
||||||
config/local_state.json
|
|
||||||
config/version_policy.dat
|
|
||||||
update/
|
|
||||||
update_temp/
|
|
||||||
```
|
|
||||||
|
|
||||||
所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`。
|
|
||||||
|
|
||||||
推荐联调目录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
D:\SimCAE_Release\
|
|
||||||
C:\Users\<你的用户名>\Desktop\SimCAE_Release\
|
|
||||||
```
|
|
||||||
|
|
||||||
如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。
|
|
||||||
|
|
||||||
## SimCAE 目录结构建议
|
|
||||||
|
|
||||||
SimCAE 当前安装目录是根目录下有 `bin/`、`Licenses/`、`installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
SimCAE/
|
|
||||||
bin/
|
|
||||||
Launcher.exe
|
|
||||||
Updater.exe
|
|
||||||
Bootstrap.exe
|
|
||||||
SimCAE.exe
|
|
||||||
config/
|
|
||||||
app_config.json
|
|
||||||
manifest_public_key.pem
|
|
||||||
update/
|
|
||||||
manifest_cache/
|
|
||||||
Qt5Core.dll
|
|
||||||
...
|
|
||||||
Licenses/
|
|
||||||
installerResources/
|
|
||||||
maintenancetool.exe
|
|
||||||
```
|
|
||||||
|
|
||||||
这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root` 填 `..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update`。
|
|
||||||
|
|
||||||
注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll`、`platforms/`、`imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。
|
|
||||||
|
|
||||||
## app_config.json 关键字段
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"app_id": "simcae",
|
|
||||||
"app_name": "SimCAE",
|
|
||||||
"channel": "stable",
|
|
||||||
"current_version": "1.0.0",
|
|
||||||
"client_protocol": "3",
|
|
||||||
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
|
|
||||||
"license_key": "",
|
|
||||||
"api_base_url": "http://YOUR_SERVER_IP:8000",
|
|
||||||
"client_token": "SimCAEClientToken2026",
|
|
||||||
"request_timeout_ms": "5000",
|
|
||||||
"temp_folder": "update_temp",
|
|
||||||
"device_id": "",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
字段说明:
|
|
||||||
|
|
||||||
- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。
|
|
||||||
- `channel`:发布渠道,例如 `stable`、`beta`、`dev`。
|
|
||||||
- `current_version`:当前客户端初始版本。
|
|
||||||
- `client_protocol`:客户端协议号,当前建议为 `3`。
|
|
||||||
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。
|
|
||||||
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。
|
|
||||||
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
|
|
||||||
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
|
|
||||||
- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`。
|
|
||||||
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`。
|
|
||||||
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
|
|
||||||
|
|
||||||
## 业务主程序需要配合什么
|
|
||||||
|
|
||||||
当前安全模式下,业务主程序需要配合两件事:
|
|
||||||
|
|
||||||
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
|
|
||||||
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
|
|
||||||
|
|
||||||
当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了:
|
|
||||||
|
|
||||||
- 启动票据校验。
|
|
||||||
- 本地 License/设备身份校验。
|
|
||||||
- 本地策略校验。
|
|
||||||
- Manifest 完整性校验。
|
|
||||||
- 健康标记写入。
|
|
||||||
|
|
||||||
如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。
|
|
||||||
|
|
||||||
## 如何生成 SDK 包
|
|
||||||
|
|
||||||
在 Windows PowerShell 中执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd client
|
|
||||||
.\package-sdk.ps1 `
|
|
||||||
-SourceDir .\out\bin `
|
|
||||||
-OutputDir .\dist\UpdateClientSDK `
|
|
||||||
-ZipFile .\dist\UpdateClientSDK.zip `
|
|
||||||
-SdkVersion 0.1.0
|
|
||||||
```
|
|
||||||
|
|
||||||
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`。
|
|
||||||
|
|
||||||
生成结果:
|
|
||||||
|
|
||||||
```text
|
|
||||||
dist/UpdateClientSDK/
|
|
||||||
SimCAE自动升级SDK接入说明_v0.1.docx
|
|
||||||
sdk_manifest.json
|
|
||||||
bin/
|
|
||||||
config/
|
|
||||||
app_config.json
|
|
||||||
manifest_public_key.pem
|
|
||||||
scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
把 `UpdateClientSDK.zip` 发给接入方即可。
|
|
||||||
|
|
||||||
## 如何生成某个产品的最终客户端包
|
|
||||||
|
|
||||||
SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd client
|
|
||||||
.\package-client.ps1 `
|
|
||||||
-SourceDir .\out\bin `
|
|
||||||
-ConfigFile .\config\app_config.json `
|
|
||||||
-OutputDir .\dist\UpdateClient `
|
|
||||||
-ZipFile .\dist\UpdateClient.zip
|
|
||||||
```
|
|
||||||
|
|
||||||
`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。
|
|
||||||
|
|
||||||
## 常见错误
|
|
||||||
|
|
||||||
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
|
|
||||||
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
|
|
||||||
3. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。
|
|
||||||
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
|
|
||||||
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
|
|
||||||
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
|
|
||||||
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`。
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
|
if(WIN32 AND MSVC)
|
||||||
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
# 强制所有编译、设计时生成均使用x64,禁止Win32
|
||||||
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32")
|
||||||
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64)
|
||||||
# 关闭VS自动Win32设计时预生成
|
# 关闭VS自动Win32设计时预生成
|
||||||
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF)
|
||||||
# 清除32位Strawberry Perl路径干扰
|
endif()
|
||||||
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include")
|
|
||||||
list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib")
|
|
||||||
|
|
||||||
project(Updater)
|
project(Updater)
|
||||||
set(SRC
|
set(SRC
|
||||||
@@ -14,6 +13,8 @@ set(SRC
|
|||||||
UpdaterLogic.cpp
|
UpdaterLogic.cpp
|
||||||
UpdateTransaction.h
|
UpdateTransaction.h
|
||||||
UpdateTransaction.cpp
|
UpdateTransaction.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/i18n/update-client.qrc
|
||||||
|
${CMAKE_SOURCE_DIR}/config/server_config.qrc
|
||||||
)
|
)
|
||||||
add_executable(Updater ${SRC})
|
add_executable(Updater ${SRC})
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ bool UpdateTransaction::copyOverwrite(const QString& source, const QString& dest
|
|||||||
|
|
||||||
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
|
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["transaction_id"] = m_transactionId;
|
||||||
m_state["from_version"] = m_fromVersion;
|
m_state["from_version"] = m_fromVersion;
|
||||||
m_state["to_version"] = m_toVersion;
|
m_state["to_version"] = m_toVersion;
|
||||||
@@ -148,6 +150,8 @@ bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
|
|||||||
|
|
||||||
bool UpdateTransaction::backupCurrentFiles()
|
bool UpdateTransaction::backupCurrentFiles()
|
||||||
{
|
{
|
||||||
|
// 替换前先备份所有将被修改或删除的文件。
|
||||||
|
// 后续健康检查失败时,可以用这些备份恢复到升级前版本。
|
||||||
if (!writeState("waiting_mainapp_exit")) return false;
|
if (!writeState("waiting_mainapp_exit")) return false;
|
||||||
QStringList paths = m_changedPaths;
|
QStringList paths = m_changedPaths;
|
||||||
paths.append(m_obsoletePaths);
|
paths.append(m_obsoletePaths);
|
||||||
@@ -163,6 +167,8 @@ bool UpdateTransaction::backupCurrentFiles()
|
|||||||
|
|
||||||
bool UpdateTransaction::installStagedFiles(QString* failedPath)
|
bool UpdateTransaction::installStagedFiles(QString* failedPath)
|
||||||
{
|
{
|
||||||
|
// staging 目录里只放已经下载并校验过 hash 的新文件。
|
||||||
|
// 真正覆盖安装目录时如果任意一个文件失败,就进入 rollback_required。
|
||||||
if (!writeState("replacing")) return false;
|
if (!writeState("replacing")) return false;
|
||||||
for (const QString& path : m_changedPaths) {
|
for (const QString& path : m_changedPaths) {
|
||||||
const QString source = QDir(m_stagingDir).filePath(path);
|
const QString source = QDir(m_stagingDir).filePath(path);
|
||||||
@@ -226,6 +232,7 @@ QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePat
|
|||||||
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
|
bool UpdateTransaction::recoverInterrupted(const QString& installDir, const QString& updateDir,
|
||||||
QString* restoredVersion, QString* errorMessage)
|
QString* restoredVersion, QString* errorMessage)
|
||||||
{
|
{
|
||||||
|
// 启动时恢复未完成事务:如果上次升级停在替换/验证/回滚中间,优先恢复到可启动状态。
|
||||||
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
|
QString stateFile = QDir(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)
|
||||||
.filePath("upgrade_state.json");
|
.filePath("upgrade_state.json");
|
||||||
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
|
const QString legacyStateFile = QDir(installDir).filePath("update/upgrade_state.json");
|
||||||
|
|||||||
+392
-162
@@ -3,6 +3,7 @@
|
|||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QEventLoop>
|
#include <QEventLoop>
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
@@ -14,8 +15,11 @@
|
|||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QUrlQuery>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include "ConfigHelper.h"
|
#include "ConfigHelper.h"
|
||||||
|
#include "UpdatePathPolicy.h"
|
||||||
|
|
||||||
#ifdef HAVE_OPENSSL
|
#ifdef HAVE_OPENSSL
|
||||||
#include <openssl/pem.h>
|
#include <openssl/pem.h>
|
||||||
@@ -24,33 +28,142 @@
|
|||||||
#include <openssl/err.h>
|
#include <openssl/err.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString trimBaseUrl(QString value)
|
||||||
|
{
|
||||||
|
value = value.trimmed();
|
||||||
|
while (value.endsWith(QLatin1Char('/')))
|
||||||
|
value.chop(1);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
void addQueryValue(QUrlQuery& query, const QString& key, const QString& value)
|
||||||
|
{
|
||||||
|
const QString trimmed = value.trimmed();
|
||||||
|
if (!trimmed.isEmpty())
|
||||||
|
query.addQueryItem(key, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString serverDetailMessage(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 obj = detail.toObject();
|
||||||
|
const QString msg = obj.value(QStringLiteral("msg")).toString();
|
||||||
|
if (!msg.isEmpty()) return msg;
|
||||||
|
const QString error = obj.value(QStringLiteral("error")).toString();
|
||||||
|
if (!error.isEmpty()) return error;
|
||||||
|
}
|
||||||
|
return detail.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
qint64 manifestFileSize(const QJsonObject& file)
|
||||||
|
{
|
||||||
|
if (file.contains(QStringLiteral("sizeBytes")))
|
||||||
|
return file.value(QStringLiteral("sizeBytes")).toVariant().toLongLong();
|
||||||
|
if (file.contains(QStringLiteral("size")))
|
||||||
|
return file.value(QStringLiteral("size")).toVariant().toLongLong();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString absoluteDownloadUrl(const QString& baseUrl, const QString& downloadUrl)
|
||||||
|
{
|
||||||
|
const QString trimmed = downloadUrl.trimmed();
|
||||||
|
if (trimmed.startsWith(QStringLiteral("http://"), Qt::CaseInsensitive)
|
||||||
|
|| trimmed.startsWith(QStringLiteral("https://"), Qt::CaseInsensitive))
|
||||||
|
return trimmed;
|
||||||
|
if (trimmed.startsWith(QLatin1Char('/')))
|
||||||
|
return trimBaseUrl(baseUrl) + trimmed;
|
||||||
|
return trimBaseUrl(baseUrl) + QLatin1Char('/') + trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
UpdaterLogic::UpdaterLogic(QObject* parent)
|
UpdaterLogic::UpdaterLogic(QObject* parent)
|
||||||
: QObject(parent)
|
: QObject(parent)
|
||||||
{
|
{
|
||||||
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
|
m_serverAddr = trimBaseUrl(ConfigHelper::instance().getValue("Server", "api_base_url"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer,
|
||||||
|
int versionId, const QString& releaseId)
|
||||||
{
|
{
|
||||||
QString url = m_serverAddr + "/api/v1/update/manifest";
|
// Manifest 由服务端按版本动态生成,描述目标版本包含哪些文件以及每个文件的 SHA256。
|
||||||
QJsonObject body;
|
// Updater 先拿到 Manifest,再请求下载 URL,最后按 Manifest 校验本地文件。
|
||||||
body["app_id"] = appId;
|
m_error.clear();
|
||||||
body["channel"] = channel;
|
Q_UNUSED(versionId);
|
||||||
body["version"] = targetVer;
|
m_manifestSha256.clear();
|
||||||
body["version_id"] = versionId;
|
m_manifestSignature.clear();
|
||||||
|
m_manifestSignatureAlg.clear();
|
||||||
|
m_manifestKeyId.clear();
|
||||||
|
m_manifestSigned = false;
|
||||||
|
m_fileItems.clear();
|
||||||
|
|
||||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
QUrl url(m_serverAddr + QStringLiteral("/api/v1/client/update/manifest"));
|
||||||
|
QUrlQuery query;
|
||||||
|
addQueryValue(query, QStringLiteral("releaseId"), releaseId);
|
||||||
|
addQueryValue(query, QStringLiteral("productCode"), appId);
|
||||||
|
addQueryValue(query, QStringLiteral("version"), targetVer);
|
||||||
|
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);
|
||||||
|
|
||||||
|
m_http.getRequest(url.toString(QUrl::FullyEncoded),
|
||||||
|
[this, appId, channel, targetVer, releaseId](int code, const QJsonObject& resp)
|
||||||
{
|
{
|
||||||
qDebug() << "Manifest API returned code:" << code;
|
qDebug() << "Manifest API returned code:" << code;
|
||||||
m_manifest = QJsonObject();
|
m_manifest = QJsonObject();
|
||||||
m_manifestText.clear();
|
m_manifestText.clear();
|
||||||
|
m_manifestSha256.clear();
|
||||||
|
m_manifestSignature.clear();
|
||||||
|
m_manifestSignatureAlg.clear();
|
||||||
|
m_manifestKeyId.clear();
|
||||||
|
m_manifestSigned = false;
|
||||||
|
|
||||||
if (code == 200)
|
if (code == 200)
|
||||||
{
|
{
|
||||||
if (resp.contains("manifest_text") && resp.contains("manifest"))
|
const QJsonObject envelope = resp.value(QStringLiteral("data")).isObject()
|
||||||
|
? resp.value(QStringLiteral("data")).toObject()
|
||||||
|
: resp;
|
||||||
|
QString manifestText = envelope.value(QStringLiteral("manifestText")).toString();
|
||||||
|
if (manifestText.isEmpty())
|
||||||
|
manifestText = envelope.value(QStringLiteral("manifest_text")).toString();
|
||||||
|
const QJsonObject manifest = envelope.value(QStringLiteral("manifest")).toObject();
|
||||||
|
if (!manifestText.isEmpty() && !manifest.isEmpty())
|
||||||
{
|
{
|
||||||
m_manifestText = resp["manifest_text"].toString();
|
m_manifestText = manifestText;
|
||||||
m_manifest = resp["manifest"].toObject();
|
m_manifest = manifest;
|
||||||
|
m_manifestSha256 = envelope.value(QStringLiteral("manifestSha256")).toString(
|
||||||
|
envelope.value(QStringLiteral("manifest_sha256")).toString());
|
||||||
|
m_manifestSignature = envelope.value(QStringLiteral("signature")).toString(
|
||||||
|
m_manifest.value(QStringLiteral("signature")).toString());
|
||||||
|
m_manifestSignatureAlg = envelope.value(QStringLiteral("signatureAlg")).toString(
|
||||||
|
envelope.value(QStringLiteral("signature_alg")).toString());
|
||||||
|
m_manifestKeyId = envelope.value(QStringLiteral("keyId")).toString(
|
||||||
|
envelope.value(QStringLiteral("key_id")).toString());
|
||||||
|
m_manifestSigned = envelope.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
|
||||||
qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
|
qDebug() << "Received manifest version:" << m_manifest.value("version").toString();
|
||||||
m_fileItems.clear();
|
m_fileItems.clear();
|
||||||
QJsonArray files = m_manifest.value("files").toArray();
|
QJsonArray files = m_manifest.value("files").toArray();
|
||||||
@@ -58,21 +171,30 @@ void UpdaterLogic::getManifest(const QString& appId, const QString& channel, con
|
|||||||
{
|
{
|
||||||
QJsonObject fileObj = fileItem.toObject();
|
QJsonObject fileObj = fileItem.toObject();
|
||||||
FileDownloadItem fi;
|
FileDownloadItem fi;
|
||||||
fi.path = fileObj.value("path").toString();
|
fi.path = fileObj.value(QStringLiteral("path")).toString();
|
||||||
fi.sha256 = fileObj.value("sha256").toString();
|
fi.sha256 = fileObj.value(QStringLiteral("sha256")).toString();
|
||||||
fi.size = fileObj.value("size").toVariant().toLongLong();
|
fi.size = manifestFileSize(fileObj);
|
||||||
fi.url = m_serverAddr + "/api/v1/update/file/" + fi.path; // placeholder, actual download URL uses download-url or signed object URL
|
fi.url = absoluteDownloadUrl(m_serverAddr,
|
||||||
|
fileObj.value(QStringLiteral("downloadUrl")).toString());
|
||||||
m_fileItems.append(fi);
|
m_fileItems.append(fi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
qDebug() << "Manifest response missing fields";
|
qDebug() << "Manifest response missing fields";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Target version manifest response is incomplete. Stage: download target manifest. Product: %1, channel: %2, version: %3, release id: %4.")
|
||||||
|
.arg(appId, channel, targetVer, releaseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
qDebug() << "Failed to get manifest";
|
qDebug() << "Failed to get manifest";
|
||||||
|
const QString detail = serverDetailMessage(resp);
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot download target version manifest. Stage: download target manifest. HTTP status: %1. Product: %2, channel: %3, version: %4, release id: %5.%6")
|
||||||
|
.arg(QString::number(code), appId, channel, targetVer, releaseId,
|
||||||
|
detail.isEmpty() ? QString() : QCoreApplication::translate("UpdaterLogic", "\nServer message: %1").arg(detail));
|
||||||
}
|
}
|
||||||
emit fetchUrlFinished();
|
emit fetchUrlFinished();
|
||||||
});
|
});
|
||||||
@@ -85,12 +207,17 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
|||||||
Q_UNUSED(signatureBase64);
|
Q_UNUSED(signatureBase64);
|
||||||
Q_UNUSED(publicKeyPath);
|
Q_UNUSED(publicKeyPath);
|
||||||
qDebug() << "OpenSSL not available, cannot verify manifest signature";
|
qDebug() << "OpenSSL not available, cannot verify manifest signature";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot verify manifest signature because OpenSSL support is unavailable. Stage: manifest signature verification.");
|
||||||
return false;
|
return false;
|
||||||
#else
|
#else
|
||||||
QFile keyFile(publicKeyPath);
|
QFile keyFile(publicKeyPath);
|
||||||
if (!keyFile.open(QIODevice::ReadOnly))
|
if (!keyFile.open(QIODevice::ReadOnly))
|
||||||
{
|
{
|
||||||
qDebug() << "Cannot open public key file:" << publicKeyPath;
|
qDebug() << "Cannot open public key file:" << publicKeyPath;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot open manifest public key. Stage: manifest signature verification. Public key path: %1.")
|
||||||
|
.arg(publicKeyPath);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +228,9 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
|||||||
if (!bio)
|
if (!bio)
|
||||||
{
|
{
|
||||||
qDebug() << "BIO_new_mem_buf failed";
|
qDebug() << "BIO_new_mem_buf failed";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot parse manifest public key buffer. Stage: manifest signature verification. Public key path: %1.")
|
||||||
|
.arg(publicKeyPath);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +239,9 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
|||||||
if (!pkey)
|
if (!pkey)
|
||||||
{
|
{
|
||||||
qDebug() << "PEM_read_bio_PUBKEY failed";
|
qDebug() << "PEM_read_bio_PUBKEY failed";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Manifest public key is invalid. Stage: manifest signature verification. Public key path: %1.")
|
||||||
|
.arg(publicKeyPath);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +251,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
|||||||
{
|
{
|
||||||
EVP_PKEY_free(pkey);
|
EVP_PKEY_free(pkey);
|
||||||
qDebug() << "EVP_MD_CTX_new failed";
|
qDebug() << "EVP_MD_CTX_new failed";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot create OpenSSL verification context. Stage: manifest signature verification.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +274,8 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
|||||||
if (!ok)
|
if (!ok)
|
||||||
{
|
{
|
||||||
qDebug() << "Manifest signature verification failed";
|
qDebug() << "Manifest signature verification failed";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Manifest RSA signature is invalid. Stage: manifest signature verification. This usually means the manifest was not signed by the matching server private key, the client public key is wrong, or the manifest content was changed.");
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
#endif
|
#endif
|
||||||
@@ -146,17 +283,40 @@ bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& sig
|
|||||||
|
|
||||||
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
||||||
{
|
{
|
||||||
|
// 验签用的是客户端随 SDK 分发的公钥。
|
||||||
|
// 只要服务端私钥没有泄漏,客户端就能发现被篡改的 Manifest。
|
||||||
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
|
||||||
{
|
{
|
||||||
qDebug() << "No manifest available to verify";
|
qDebug() << "No manifest available to verify";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"No manifest is available for signature verification. Stage: manifest signature verification. The target manifest may not have been downloaded successfully.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
QString signature = m_manifest.value("signature").toString();
|
if (!m_manifestSha256.isEmpty()) {
|
||||||
if (signature.isEmpty())
|
const QString actualSha = QString::fromLatin1(
|
||||||
{
|
QCryptographicHash::hash(m_manifestText.toUtf8(), QCryptographicHash::Sha256).toHex());
|
||||||
qDebug() << "Manifest signature empty";
|
if (actualSha.compare(m_manifestSha256, Qt::CaseInsensitive) != 0) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Manifest SHA-256 does not match the server envelope. Stage: manifest digest verification.\nExpected SHA-256: %1\nActual SHA-256: %2")
|
||||||
|
.arg(m_manifestSha256, actualSha);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool requireSignature = configFlag(QStringLiteral("require_manifest_signature"));
|
||||||
|
const QString signature = m_manifestSignature.trimmed();
|
||||||
|
if (signature.isEmpty() || !m_manifestSigned)
|
||||||
|
{
|
||||||
|
if (requireSignature) {
|
||||||
|
qDebug() << "Manifest signature empty";
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"The manifest does not contain a signature, but require_manifest_signature is enabled. Stage: manifest signature verification.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
qDebug() << "Manifest is unsigned; digest verification passed and require_manifest_signature is disabled.";
|
||||||
|
m_error.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
QString path = publicKeyPath;
|
QString path = publicKeyPath;
|
||||||
if (!QFile::exists(path))
|
if (!QFile::exists(path))
|
||||||
@@ -168,50 +328,96 @@ bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
|
|||||||
|
|
||||||
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
|
||||||
{
|
{
|
||||||
if (m_manifest.isEmpty())
|
// 启动后的完整性检查依赖本地 Manifest 缓存。
|
||||||
|
// 升级成功后缓存签名清单,下一次离线启动也能校验当前版本文件。
|
||||||
|
if (m_manifest.isEmpty()) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot save manifest cache because the target manifest is empty. Stage: save target manifest cache.");
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QDir dir(cacheDir);
|
QDir dir(cacheDir);
|
||||||
if (!dir.exists() && !dir.mkpath("."))
|
if (!dir.exists() && !dir.mkpath(".")) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot create manifest cache directory. Stage: save target manifest cache. Directory: %1.")
|
||||||
|
.arg(cacheDir);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QString version = m_manifest.value("version").toString();
|
QString version = m_manifest.value("version").toString();
|
||||||
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
||||||
QFile file(filePath);
|
QFile file(filePath);
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot write manifest cache file. Stage: save target manifest cache. File: %1. Error: %2.")
|
||||||
|
.arg(filePath, file.errorString());
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject wrapper;
|
QJsonObject wrapper;
|
||||||
wrapper["manifest"] = m_manifest;
|
wrapper["manifest"] = m_manifest;
|
||||||
|
wrapper["manifestText"] = m_manifestText;
|
||||||
wrapper["manifest_text"] = m_manifestText;
|
wrapper["manifest_text"] = m_manifestText;
|
||||||
|
wrapper["manifestSha256"] = m_manifestSha256;
|
||||||
|
wrapper["signature"] = m_manifestSignature;
|
||||||
|
wrapper["signatureAlg"] = m_manifestSignatureAlg;
|
||||||
|
wrapper["keyId"] = m_manifestKeyId;
|
||||||
|
wrapper["signed"] = m_manifestSigned;
|
||||||
|
|
||||||
QJsonDocument doc(wrapper);
|
QJsonDocument doc(wrapper);
|
||||||
file.write(doc.toJson(QJsonDocument::Indented));
|
file.write(doc.toJson(QJsonDocument::Indented));
|
||||||
file.close();
|
file.close();
|
||||||
qDebug() << "Manifest cached to" << filePath;
|
qDebug() << "Manifest cached to" << filePath;
|
||||||
|
m_error.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
|
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
|
||||||
{
|
{
|
||||||
|
m_error.clear();
|
||||||
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
QString filePath = cacheDir + "/manifest_" + version + ".json";
|
||||||
QFile file(filePath);
|
QFile file(filePath);
|
||||||
if (!file.exists() || !file.open(QIODevice::ReadOnly))
|
if (!file.exists() || !file.open(QIODevice::ReadOnly)) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot read cached signed manifest. Stage: read local manifest cache. Version: %1. File: %2. This cache is created after a version is installed successfully.")
|
||||||
|
.arg(version, filePath);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QByteArray raw = file.readAll();
|
QByteArray raw = file.readAll();
|
||||||
file.close();
|
file.close();
|
||||||
QJsonDocument doc = QJsonDocument::fromJson(raw);
|
QJsonDocument doc = QJsonDocument::fromJson(raw);
|
||||||
if (!doc.isObject())
|
if (!doc.isObject()) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cached signed manifest is not valid JSON. Stage: read local manifest cache. Version: %1. File: %2.")
|
||||||
|
.arg(version, filePath);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QJsonObject wrapper = doc.object();
|
QJsonObject wrapper = doc.object();
|
||||||
if (!wrapper.contains("manifest") || !wrapper.contains("manifest_text"))
|
if (!wrapper.contains("manifest")
|
||||||
|
|| (!wrapper.contains("manifestText") && !wrapper.contains("manifest_text"))) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cached signed manifest is incomplete. Stage: read local manifest cache. Version: %1. File: %2.")
|
||||||
|
.arg(version, filePath);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
m_manifest = wrapper["manifest"].toObject();
|
m_manifest = wrapper["manifest"].toObject();
|
||||||
m_manifestText = wrapper["manifest_text"].toString();
|
m_manifestText = wrapper.value(QStringLiteral("manifestText")).toString();
|
||||||
|
if (m_manifestText.isEmpty())
|
||||||
|
m_manifestText = wrapper.value(QStringLiteral("manifest_text")).toString();
|
||||||
|
m_manifestSha256 = wrapper.value(QStringLiteral("manifestSha256")).toString(
|
||||||
|
wrapper.value(QStringLiteral("manifest_sha256")).toString());
|
||||||
|
m_manifestSignature = wrapper.value(QStringLiteral("signature")).toString(
|
||||||
|
m_manifest.value(QStringLiteral("signature")).toString());
|
||||||
|
m_manifestSignatureAlg = wrapper.value(QStringLiteral("signatureAlg")).toString(
|
||||||
|
wrapper.value(QStringLiteral("signature_alg")).toString());
|
||||||
|
m_manifestKeyId = wrapper.value(QStringLiteral("keyId")).toString(
|
||||||
|
wrapper.value(QStringLiteral("key_id")).toString());
|
||||||
|
m_manifestSigned = wrapper.value(QStringLiteral("signed")).toBool(!m_manifestSignature.isEmpty());
|
||||||
qDebug() << "Loaded cached manifest" << version;
|
qDebug() << "Loaded cached manifest" << version;
|
||||||
|
m_error.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,33 +469,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
|||||||
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
|
||||||
}
|
}
|
||||||
|
|
||||||
QSet<QString> protectedPaths{
|
|
||||||
QStringLiteral("bootstrap.exe"),
|
|
||||||
QStringLiteral("launcher.exe"),
|
|
||||||
QStringLiteral("updater.exe"),
|
|
||||||
QStringLiteral("client.ini"),
|
|
||||||
QStringLiteral("config/app_config.json"),
|
|
||||||
QStringLiteral("config/local_state.json"),
|
|
||||||
QStringLiteral("config/client_identity.dat"),
|
|
||||||
QStringLiteral("config/version_policy.dat")
|
|
||||||
};
|
|
||||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
|
||||||
if (!runtimePrefix.isEmpty()) {
|
|
||||||
const QStringList runtimeProtected{
|
|
||||||
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"),
|
|
||||||
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
|
|
||||||
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
|
|
||||||
QStringLiteral("config/version_policy.dat")
|
|
||||||
};
|
|
||||||
for (const QString& path : runtimeProtected)
|
|
||||||
protectedPaths.insert(runtimePrefix + "/" + path);
|
|
||||||
}
|
|
||||||
QStringList obsolete;
|
QStringList obsolete;
|
||||||
QSet<QString> seen;
|
QSet<QString> seen;
|
||||||
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
|
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
|
||||||
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
|
const QJsonObject item = value.toObject();
|
||||||
|
if (item.contains(QStringLiteral("required"))
|
||||||
|
&& !item.value(QStringLiteral("required")).toBool(true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||||
const QString folded = path.toCaseFolded();
|
const QString folded = path.toCaseFolded();
|
||||||
if (!isSafeRelativePath(path) || protectedPaths.contains(folded)
|
if (!isSafeRelativePath(path) || isRuntimeProtectedPath(path)
|
||||||
|| newPaths.contains(folded) || seen.contains(folded))
|
|| newPaths.contains(folded) || seen.contains(folded))
|
||||||
continue;
|
continue;
|
||||||
seen.insert(folded);
|
seen.insert(folded);
|
||||||
@@ -301,8 +491,17 @@ QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest
|
|||||||
|
|
||||||
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
|
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
|
||||||
{
|
{
|
||||||
if (m_manifest.isEmpty())
|
m_error.clear();
|
||||||
|
const QString stage = installedDir.isEmpty()
|
||||||
|
? QCoreApplication::translate("UpdaterLogic", "installed version verification")
|
||||||
|
: QCoreApplication::translate("UpdaterLogic", "downloaded/staged file verification");
|
||||||
|
const QString version = m_manifest.value(QStringLiteral("version")).toString();
|
||||||
|
if (m_manifest.isEmpty()) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"No manifest is available. Stage: %1. The updater cannot know which files and hashes should be verified.")
|
||||||
|
.arg(stage);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const QJsonArray files = m_manifest.value("files").toArray();
|
const QJsonArray files = m_manifest.value("files").toArray();
|
||||||
for (const auto& item : files)
|
for (const auto& item : files)
|
||||||
@@ -310,8 +509,12 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
|
|||||||
const QJsonObject fileObject = item.toObject();
|
const QJsonObject fileObject = item.toObject();
|
||||||
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
|
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
|
||||||
const QString expectedSha = fileObject.value("sha256").toString();
|
const QString expectedSha = fileObject.value("sha256").toString();
|
||||||
if (!isSafeRelativePath(path))
|
if (!isSafeRelativePath(path)) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Manifest contains an unsafe file path. Stage: %1. Version: %2. Path: %3.")
|
||||||
|
.arg(stage, version, path);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
if (isRuntimeProtectedPath(path)) {
|
if (isRuntimeProtectedPath(path)) {
|
||||||
qDebug() << "Runtime-protected manifest entry ignored:" << path;
|
qDebug() << "Runtime-protected manifest entry ignored:" << path;
|
||||||
continue;
|
continue;
|
||||||
@@ -324,16 +527,32 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString&
|
|||||||
if (!QFile::exists(fullPath))
|
if (!QFile::exists(fullPath))
|
||||||
{
|
{
|
||||||
qDebug() << "Manifest file missing from staging and installation:" << path;
|
qDebug() << "Manifest file missing from staging and installation:" << path;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"A required file is missing. Stage: %1. Version: %2. Manifest path: %3. Checked path: %4. If this is a downloaded update, the file was not downloaded or staged correctly; if this is startup verification, the installed file may have been deleted.")
|
||||||
|
.arg(stage, version, path, fullPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const qint64 expectedSize = manifestFileSize(fileObject);
|
||||||
|
if (expectedSize >= 0 && QFileInfo(fullPath).size() != expectedSize)
|
||||||
|
{
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"File size does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected size: %5 bytes\nActual size: %6 bytes")
|
||||||
|
.arg(stage, version, path, fullPath,
|
||||||
|
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const QString actualSha = calcLocalFileSha256(fullPath);
|
const QString actualSha = calcLocalFileSha256(fullPath);
|
||||||
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
|
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
|
||||||
{
|
{
|
||||||
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
|
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"File SHA-256 does not match the signed manifest. Stage: %1. Version: %2. Manifest path: %3. Local path: %4.\nExpected SHA-256: %5\nActual SHA-256: %6\nIf this happens during download, clear the update cache and retry. If this happens during startup or after installation, the local file differs from the published version.")
|
||||||
|
.arg(stage, version, path, fullPath, expectedSha, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
qDebug() << "Full manifest validation passed using staging plus installed files";
|
qDebug() << "Full manifest validation passed using staging plus installed files";
|
||||||
|
m_error.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,86 +560,70 @@ bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString&
|
|||||||
{
|
{
|
||||||
m_offlineError.clear();
|
m_offlineError.clear();
|
||||||
QFile package(packagePath);
|
QFile package(packagePath);
|
||||||
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; }
|
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot open the offline update package"); return false; }
|
||||||
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; }
|
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package format identifier is invalid"); return false; }
|
||||||
const QByteArray lengthBytes = package.read(8);
|
const QByteArray lengthBytes = package.read(8);
|
||||||
if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; }
|
if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete"); return false; }
|
||||||
quint64 headerSize = 0;
|
quint64 headerSize = 0;
|
||||||
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
|
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
|
||||||
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; }
|
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header length is invalid"); return false; }
|
||||||
QJsonParseError error;
|
QJsonParseError error;
|
||||||
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
|
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
|
||||||
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; }
|
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header JSON is invalid"); return false; }
|
||||||
const QJsonObject wrapper = wrapperDoc.object();
|
const QJsonObject wrapper = wrapperDoc.object();
|
||||||
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
|
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
|
||||||
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||||
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem";
|
const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem";
|
||||||
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; }
|
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package RSA signature is invalid"); return false; }
|
||||||
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
|
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
|
||||||
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; }
|
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package signature metadata is invalid"); return false; }
|
||||||
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; }
|
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The manifest digest does not match the package signature"); return false; }
|
||||||
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
|
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
|
||||||
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; }
|
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid"); return false; }
|
||||||
manifest.insert("signature", wrapper.value("manifest_signature").toString());
|
manifest.insert("signature", wrapper.value("manifest_signature").toString());
|
||||||
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
|
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
|
||||||
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; }
|
m_manifestSha256 = packageMeta.value("manifest_sha256").toString();
|
||||||
if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; }
|
m_manifestSignature = wrapper.value("manifest_signature").toString();
|
||||||
|
m_manifestSignatureAlg = wrapper.value("signature_alg").toString("RSA-SHA256");
|
||||||
|
m_manifestKeyId = wrapper.value("key_id").toString();
|
||||||
|
m_manifestSigned = !m_manifestSignature.isEmpty();
|
||||||
|
const QString manifestProduct = manifest.value("productCode").toString(manifest.value("app_id").toString());
|
||||||
|
const QString packageProduct = packageMeta.value("productCode").toString(packageMeta.value("app_id").toString());
|
||||||
|
if (manifestProduct != packageProduct || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Package information does not match manifest identity"); return false; }
|
||||||
|
if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid"); return false; }
|
||||||
const qint64 payloadStart = 16 + qint64(headerSize);
|
const qint64 payloadStart = 16 + qint64(headerSize);
|
||||||
const QJsonArray entries = packageMeta.value("files").toArray();
|
const QJsonArray entries = packageMeta.value("files").toArray();
|
||||||
for (const QJsonValue& value : entries) {
|
for (const QJsonValue& value : entries) {
|
||||||
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||||
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
|
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
|
||||||
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; }
|
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-range data: %1").arg(path); return false; }
|
||||||
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
|
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
|
||||||
|
if (isRuntimeProtectedPath(path)) continue;
|
||||||
if (stagingDir.isEmpty()) continue;
|
if (stagingDir.isEmpty()) continue;
|
||||||
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; }
|
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare offline file: %1").arg(path); return false; }
|
||||||
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; }
|
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create staged file: %1").arg(path); return false; }
|
||||||
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
|
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
|
||||||
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); }
|
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Failed to read offline file: %1").arg(path); return false; } hash.addData(block); remaining -= block.size(); }
|
||||||
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; }
|
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Offline file hash verification or write failed: %1").arg(path); return false; }
|
||||||
}
|
}
|
||||||
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; }
|
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package file count does not match the manifest"); return false; }
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
|
||||||
{
|
{
|
||||||
QString url = m_serverAddr + "/api/v1/update/download-url";
|
Q_UNUSED(appId);
|
||||||
QJsonObject body;
|
Q_UNUSED(channel);
|
||||||
body["app_id"] = appId;
|
Q_UNUSED(targetVer);
|
||||||
body["channel"] = channel;
|
Q_UNUSED(versionId);
|
||||||
body["version"] = targetVer;
|
m_error.clear();
|
||||||
body["version_id"] = versionId;
|
if (m_fileItems.isEmpty()) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
QJsonArray emptyFiles;
|
"The manifest does not contain any downloadable package URL. Stage: prepare authorized downloads.");
|
||||||
body["files"] = emptyFiles;
|
} else {
|
||||||
|
qDebug() << "Authorized download URLs were loaded from the SimCAE Hub manifest.";
|
||||||
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp)
|
|
||||||
{
|
|
||||||
qDebug() << "Download URL API returned code:" << code;
|
|
||||||
m_fileItems.clear();
|
|
||||||
|
|
||||||
if (code == 200)
|
|
||||||
{
|
|
||||||
QJsonArray fileArr = resp["files"].toArray();
|
|
||||||
for (auto item : fileArr)
|
|
||||||
{
|
|
||||||
QJsonObject obj = item.toObject();
|
|
||||||
FileDownloadItem fi;
|
|
||||||
fi.path = obj["path"].toString();
|
|
||||||
fi.url = obj["url"].toString();
|
|
||||||
fi.sha256 = obj["sha256"].toString();
|
|
||||||
fi.size = obj["size"].toVariant().toLongLong();
|
|
||||||
m_fileItems.append(fi);
|
|
||||||
qDebug() << "File info:" << fi.path << fi.url << fi.sha256;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
qDebug() << "Failed to get download URL";
|
|
||||||
}
|
}
|
||||||
emit fetchUrlFinished();
|
emit fetchUrlFinished();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -444,10 +647,18 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
const QString& resumePartPath)
|
const QString& resumePartPath)
|
||||||
{
|
{
|
||||||
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
|
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
|
||||||
if (!QDir().mkpath(QFileInfo(partPath).path())) return false;
|
const QString displayPath = m_currentDownloadPath.isEmpty() ? savePath : m_currentDownloadPath;
|
||||||
|
if (!QDir().mkpath(QFileInfo(partPath).path())) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot create partial download directory. Stage: download file. File: %1. Partial file: %2.")
|
||||||
|
.arg(displayPath, partPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (QFile::exists(savePath)
|
if (QFile::exists(savePath)
|
||||||
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0) {
|
||||||
|
m_error.clear();
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
QFile::remove(savePath);
|
QFile::remove(savePath);
|
||||||
|
|
||||||
for (int attempt = 0; attempt < 4; ++attempt)
|
for (int attempt = 0; attempt < 4; ++attempt)
|
||||||
@@ -463,8 +674,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
|
||||||
{
|
{
|
||||||
QFile::remove(savePath);
|
QFile::remove(savePath);
|
||||||
return QFile::rename(partPath, savePath);
|
if (QFile::rename(partPath, savePath)) {
|
||||||
|
m_error.clear();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
|
||||||
|
.arg(displayPath, partPath, savePath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const QString actualSha = calcLocalFileSha256(partPath);
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cached partial file has the expected size but wrong SHA-256. Stage: resume download. File: %1.\nExpected SHA-256: %2\nActual SHA-256: %3\nThe partial cache will be deleted and downloaded again.")
|
||||||
|
.arg(displayPath, expectSha256, actualSha);
|
||||||
QFile::remove(partPath);
|
QFile::remove(partPath);
|
||||||
existingSize = 0;
|
existingSize = 0;
|
||||||
}
|
}
|
||||||
@@ -475,13 +697,19 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
if (!partFile.open(mode))
|
if (!partFile.open(mode))
|
||||||
{
|
{
|
||||||
qDebug() << "Cannot open partial download:" << partPath;
|
qDebug() << "Cannot open partial download:" << partPath;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot open partial download file. Stage: download file. File: %1. Partial file: %2. Error: %3.")
|
||||||
|
.arg(displayPath, partPath, partFile.errorString());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QNetworkAccessManager manager;
|
QNetworkAccessManager manager;
|
||||||
manager.setProxy(QNetworkProxy::NoProxy);
|
manager.setProxy(QNetworkProxy::NoProxy);
|
||||||
QNetworkRequest request(url);
|
QNetworkRequest request{QUrl(url)};
|
||||||
request.setTransferTimeout(60000);
|
request.setTransferTimeout(60000);
|
||||||
|
const QString clientToken = configValue(QStringLiteral("client_token"));
|
||||||
|
if (!clientToken.isEmpty())
|
||||||
|
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
|
||||||
if (existingSize > 0)
|
if (existingSize > 0)
|
||||||
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
|
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
|
||||||
|
|
||||||
@@ -514,7 +742,7 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
|
|
||||||
if (existingSize > 0 && httpStatus == 200)
|
if (existingSize > 0 && httpStatus == 200)
|
||||||
{
|
{
|
||||||
// 服务端忽略 Range,当前文件是“旧片段 + 完整响应”,必须安全重下。
|
// The server ignored Range; the current file is "old fragment + full response" and must be redownloaded safely.
|
||||||
qDebug() << "Server ignored Range; restart full download:" << savePath;
|
qDebug() << "Server ignored Range; restart full download:" << savePath;
|
||||||
QFile::remove(partPath);
|
QFile::remove(partPath);
|
||||||
}
|
}
|
||||||
@@ -528,19 +756,37 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
if (QFile::rename(partPath, savePath))
|
if (QFile::rename(partPath, savePath))
|
||||||
{
|
{
|
||||||
qDebug() << "Download completed/resumed & sha pass:" << savePath;
|
qDebug() << "Download completed/resumed & sha pass:" << savePath;
|
||||||
|
m_error.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Downloaded file passed SHA-256 verification, but cannot move it into the staging directory. Stage: download file. File: %1. From: %2. To: %3.")
|
||||||
|
.arg(displayPath, partPath, savePath);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
else if (expectedSize >= 0 && actualSize >= expectedSize)
|
else if (expectedSize >= 0 && actualSize >= expectedSize)
|
||||||
{
|
{
|
||||||
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
|
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
|
||||||
|
const QString actualSha = calcLocalFileSha256(partPath);
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Downloaded file does not match the signed manifest. Stage: download file. File: %1. HTTP status: %2.\nExpected size: %3 bytes\nActual size: %4 bytes\nExpected SHA-256: %5\nActual SHA-256: %6\nThe partial cache will be deleted and downloaded again.")
|
||||||
|
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize),
|
||||||
|
expectSha256, actualSha.isEmpty() ? QCoreApplication::translate("UpdaterLogic", "<cannot read file>") : actualSha);
|
||||||
QFile::remove(partPath);
|
QFile::remove(partPath);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Downloaded file is incomplete. Stage: download file. File: %1. HTTP status: %2. Expected size: %3 bytes, current size: %4 bytes.")
|
||||||
|
.arg(displayPath, QString::number(httpStatus), QString::number(expectedSize), QString::number(actualSize));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
|
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
|
||||||
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
|
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Download request failed. Stage: download file. File: %1. Attempt: %2/4. HTTP status: %3. Network error: %4. Partial file: %5.")
|
||||||
|
.arg(displayPath, QString::number(attempt + 1), QString::number(httpStatus), networkError, partPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attempt < 3)
|
if (attempt < 3)
|
||||||
@@ -555,42 +801,23 @@ bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (m_error.isEmpty()) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"File download failed after retries. Stage: download file. File: %1.")
|
||||||
|
.arg(displayPath);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
|
||||||
{
|
{
|
||||||
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
|
return UpdatePathPolicy::isFullUpdateProtectedPath(
|
||||||
QSet<QString> protectedPaths{
|
path, ConfigHelper::instance().runtimeRelativePath());
|
||||||
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")
|
|
||||||
};
|
|
||||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
|
||||||
if (!runtimePrefix.isEmpty()) {
|
|
||||||
const QStringList runtimeProtected{
|
|
||||||
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
|
|
||||||
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
|
|
||||||
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
|
|
||||||
};
|
|
||||||
for (const QString& protectedPath : runtimeProtected)
|
|
||||||
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
|
||||||
}
|
|
||||||
return protectedPaths.contains(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool UpdaterLogic::isSafeRelativePath(const QString& path) const
|
bool UpdaterLogic::isSafeRelativePath(const QString& path) const
|
||||||
{
|
{
|
||||||
const QString normalized = QDir::fromNativeSeparators(path);
|
return UpdatePathPolicy::isSafeRelativePath(path);
|
||||||
const QString clean = QDir::cleanPath(normalized);
|
|
||||||
return !clean.isEmpty()
|
|
||||||
&& !QDir::isAbsolutePath(clean)
|
|
||||||
&& clean != ".."
|
|
||||||
&& !clean.startsWith("../")
|
|
||||||
&& !clean.contains(":");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
||||||
@@ -621,18 +848,29 @@ qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
|
|||||||
|
|
||||||
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
|
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
|
||||||
{
|
{
|
||||||
|
m_error.clear();
|
||||||
if (m_fileItems.isEmpty())
|
if (m_fileItems.isEmpty())
|
||||||
{
|
{
|
||||||
m_downloadAllOk = false;
|
m_downloadAllOk = false;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"The target version manifest contains no downloadable files. Stage: prepare downloads.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
QDir stagingDir(tempDir);
|
QDir stagingDir(tempDir);
|
||||||
if (!FileHelper::createDir(tempDir))
|
if (!FileHelper::createDir(tempDir)) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot create update staging directory. Stage: prepare downloads. Directory: %1.")
|
||||||
|
.arg(tempDir);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
|
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
|
||||||
if (!FileHelper::createDir(resumeCacheDir))
|
if (!FileHelper::createDir(resumeCacheDir)) {
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot create download cache directory. Stage: prepare downloads. Directory: %1.")
|
||||||
|
.arg(resumeCacheDir);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
QSet<QString> activePartialNames;
|
QSet<QString> activePartialNames;
|
||||||
for (const auto& item : m_fileItems)
|
for (const auto& item : m_fileItems)
|
||||||
activePartialNames.insert(item.sha256.toLower() + ".part");
|
activePartialNames.insert(item.sha256.toLower() + ".part");
|
||||||
@@ -662,6 +900,9 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
|||||||
{
|
{
|
||||||
qDebug() << "Unsafe relative path in manifest:" << fi.path;
|
qDebug() << "Unsafe relative path in manifest:" << fi.path;
|
||||||
m_downloadAllOk = false;
|
m_downloadAllOk = false;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Manifest contains an unsafe file path. Stage: prepare file download. Path: %1.")
|
||||||
|
.arg(fi.path);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +925,9 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
|||||||
{
|
{
|
||||||
qDebug() << "Cannot create staging subdirectory:" << parentDir;
|
qDebug() << "Cannot create staging subdirectory:" << parentDir;
|
||||||
m_downloadAllOk = false;
|
m_downloadAllOk = false;
|
||||||
|
m_error = QCoreApplication::translate("UpdaterLogic",
|
||||||
|
"Cannot create staging subdirectory. Stage: prepare file download. File: %1. Directory: %2.")
|
||||||
|
.arg(relativePath, parentDir);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,19 +949,18 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe
|
|||||||
}
|
}
|
||||||
|
|
||||||
m_downloadAllOk = true;
|
m_downloadAllOk = true;
|
||||||
|
m_error.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
|
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
|
||||||
const QString& version, bool success)
|
const QString& version, bool success)
|
||||||
{
|
{
|
||||||
QJsonArray files;
|
Q_UNUSED(appId);
|
||||||
for (const FileDownloadItem& item : m_fileItems)
|
Q_UNUSED(channel);
|
||||||
files.append(QJsonObject{{"path", item.path}, {"size", item.size}});
|
Q_UNUSED(version);
|
||||||
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version},
|
Q_UNUSED(success);
|
||||||
{"result", success ? "success" : "fail"}, {"files", files}};
|
qDebug() << "Download result report is not part of the current SimCAE Hub API; skipped.";
|
||||||
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
|
|
||||||
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void UpdaterLogic::reportResult(const QString& deviceId,
|
void UpdaterLogic::reportResult(const QString& deviceId,
|
||||||
@@ -725,24 +968,11 @@ void UpdaterLogic::reportResult(const QString& deviceId,
|
|||||||
const QString& toVer,
|
const QString& toVer,
|
||||||
bool success)
|
bool success)
|
||||||
{
|
{
|
||||||
QString url = m_serverAddr + "/api/v1/update/report";
|
Q_UNUSED(deviceId);
|
||||||
|
Q_UNUSED(fromVer);
|
||||||
QJsonObject body;
|
Q_UNUSED(toVer);
|
||||||
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id");
|
Q_UNUSED(success);
|
||||||
body["device_id"] = deviceId;
|
qDebug() << "Update result report is not part of the current SimCAE Hub API; skipped.";
|
||||||
body["from_version"] = fromVer;
|
|
||||||
body["to_version"] = toVer;
|
|
||||||
|
|
||||||
if (success)
|
|
||||||
body["result"] = "success";
|
|
||||||
else
|
|
||||||
body["result"] = "fail";
|
|
||||||
|
|
||||||
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
|
|
||||||
{
|
|
||||||
Q_UNUSED(resp);
|
|
||||||
qDebug() << "Update result report returned code:" << code;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
QList<FileDownloadItem> UpdaterLogic::getFileList() const
|
QList<FileDownloadItem> UpdaterLogic::getFileList() const
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ class UpdaterLogic : public QObject
|
|||||||
public:
|
public:
|
||||||
explicit UpdaterLogic(QObject* parent = nullptr);
|
explicit UpdaterLogic(QObject* parent = nullptr);
|
||||||
|
|
||||||
void getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId);
|
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;
|
bool verifyManifestSignature(const QString& publicKeyPath = "config/manifest_public_key.pem") const;
|
||||||
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const;
|
||||||
bool saveManifestCache(const QString& cacheDir) const;
|
bool saveManifestCache(const QString& cacheDir) const;
|
||||||
bool loadManifestCache(const QString& cacheDir, const QString& version);
|
bool loadManifestCache(const QString& cacheDir, const QString& version);
|
||||||
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
|
bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString());
|
||||||
QString offlineError() const { return m_offlineError; }
|
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 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);
|
void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success);
|
||||||
@@ -62,6 +64,11 @@ private:
|
|||||||
QString m_serverAddr;
|
QString m_serverAddr;
|
||||||
QJsonObject m_manifest;
|
QJsonObject m_manifest;
|
||||||
QString m_manifestText;
|
QString m_manifestText;
|
||||||
|
QString m_manifestSha256;
|
||||||
|
QString m_manifestSignature;
|
||||||
|
QString m_manifestSignatureAlg;
|
||||||
|
QString m_manifestKeyId;
|
||||||
|
bool m_manifestSigned = false;
|
||||||
QList<FileDownloadItem> m_fileItems;
|
QList<FileDownloadItem> m_fileItems;
|
||||||
bool m_downloadAllOk = false;
|
bool m_downloadAllOk = false;
|
||||||
qint64 m_downloadTotalBytes = 0;
|
qint64 m_downloadTotalBytes = 0;
|
||||||
@@ -69,5 +76,6 @@ private:
|
|||||||
qint64 m_sessionDownloadedBytes = 0;
|
qint64 m_sessionDownloadedBytes = 0;
|
||||||
QString m_currentDownloadPath;
|
QString m_currentDownloadPath;
|
||||||
QString m_offlineError;
|
QString m_offlineError;
|
||||||
|
mutable QString m_error;
|
||||||
QElapsedTimer m_downloadTimer;
|
QElapsedTimer m_downloadTimer;
|
||||||
};
|
};
|
||||||
+202
-77
@@ -1,18 +1,18 @@
|
|||||||
#include <windows.h>
|
#include <QApplication>
|
||||||
#include <QApplication>
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QDirIterator>
|
#include <QDirIterator>
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
#include <QProgressDialog>
|
#include <QProgressDialog>
|
||||||
#include <QSaveFile>
|
#include <QSaveFile>
|
||||||
#include <QStorageInfo>
|
#include <QStorageInfo>
|
||||||
#include <QTextCodec>
|
|
||||||
#include <QThread>
|
#include <QThread>
|
||||||
|
#include <QTranslator>
|
||||||
#include "UpdaterLogic.h"
|
#include "UpdaterLogic.h"
|
||||||
#include "UpdateTransaction.h"
|
#include "UpdateTransaction.h"
|
||||||
#include "FileHelper.h"
|
#include "FileHelper.h"
|
||||||
@@ -23,21 +23,29 @@
|
|||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
SetConsoleOutputCP(65001);
|
|
||||||
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
|
|
||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
QApplication::setApplicationName("Marsco Updater");
|
QApplication::setApplicationName("SimCAE Updater");
|
||||||
|
QTranslator translator;
|
||||||
|
if (translator.load(":/i18n/update-client_zh_CN.qm"))
|
||||||
|
app.installTranslator(&translator);
|
||||||
|
|
||||||
|
const int elevatedWriteExitCode = ConfigHelper::runElevatedWriteCommandIfRequested();
|
||||||
|
if (elevatedWriteExitCode >= 0)
|
||||||
|
return elevatedWriteExitCode;
|
||||||
|
|
||||||
UpdaterLogic logic;
|
UpdaterLogic logic;
|
||||||
QString offlinePackagePath;
|
QString offlinePackagePath;
|
||||||
QString appId;
|
QString appId;
|
||||||
QString channel;
|
QString channel;
|
||||||
QString targetVersion;
|
QString targetVersion;
|
||||||
|
QString releaseId;
|
||||||
int targetVersionId = 0;
|
int targetVersionId = 0;
|
||||||
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
|
if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) {
|
||||||
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
|
offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size());
|
||||||
if (!logic.loadOfflinePackage(offlinePackagePath)) {
|
if (!logic.loadOfflinePackage(offlinePackagePath)) {
|
||||||
QMessageBox::critical(nullptr, "离线包无效", logic.offlineError());
|
QMessageBox::critical(nullptr,
|
||||||
|
QCoreApplication::translate("Updater", "Invalid Offline Package"),
|
||||||
|
logic.offlineError());
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
const QJsonObject packageManifest = logic.getManifest();
|
const QJsonObject packageManifest = logic.getManifest();
|
||||||
@@ -47,7 +55,9 @@ int main(int argc, char* argv[])
|
|||||||
targetVersionId = packageManifest.value("manifest_seq").toInt();
|
targetVersionId = packageManifest.value("manifest_seq").toInt();
|
||||||
} else {
|
} else {
|
||||||
if (argc < 5) {
|
if (argc < 5) {
|
||||||
QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。");
|
QMessageBox::critical(nullptr,
|
||||||
|
QCoreApplication::translate("Updater", "Updater Argument Error"),
|
||||||
|
QCoreApplication::translate("Updater", "The updater is missing online update arguments or an offline update package."));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
|
appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt();
|
||||||
@@ -57,6 +67,8 @@ int main(int argc, char* argv[])
|
|||||||
const QString arg = argv[i];
|
const QString arg = argv[i];
|
||||||
if (arg.startsWith("--bootstrap-resume="))
|
if (arg.startsWith("--bootstrap-resume="))
|
||||||
bootstrapResult = arg.mid(QString("--bootstrap-resume=").size());
|
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 bool resumingFromBootstrap = !bootstrapResult.isEmpty();
|
||||||
const QString runtimeDir = QApplication::applicationDirPath();
|
const QString runtimeDir = QApplication::applicationDirPath();
|
||||||
@@ -65,8 +77,16 @@ int main(int argc, char* argv[])
|
|||||||
const QString targetDir = config.installRoot();
|
const QString targetDir = config.installRoot();
|
||||||
const QString updateDir = config.updateRoot();
|
const QString updateDir = config.updateRoot();
|
||||||
QDir().mkpath(updateDir);
|
QDir().mkpath(updateDir);
|
||||||
if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
|
QString configuredProductCode = config.getValue("App", "product_code").trimmed();
|
||||||
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
|
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,
|
||||||
|
QCoreApplication::translate("Updater", "Offline Package Not Applicable"),
|
||||||
|
QCoreApplication::translate("Updater", "The update package application or channel does not match the local configuration."));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
QString fromVersion = config.getValue("App", "current_version");
|
QString fromVersion = config.getValue("App", "current_version");
|
||||||
@@ -74,13 +94,17 @@ int main(int argc, char* argv[])
|
|||||||
PolicyHelper offlinePolicy(runtimeDir);
|
PolicyHelper offlinePolicy(runtimeDir);
|
||||||
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired()
|
||||||
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
|| !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) {
|
||||||
QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
|
QMessageBox::critical(nullptr,
|
||||||
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion),
|
||||||
QVersionNumber::fromString(fromVersion)) < 0
|
QVersionNumber::fromString(fromVersion)) < 0
|
||||||
&& !offlinePolicy.allowRollback()) {
|
&& !offlinePolicy.allowRollback()) {
|
||||||
QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。");
|
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;
|
return -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,21 +115,25 @@ int main(int argc, char* argv[])
|
|||||||
QString restoredVersion;
|
QString restoredVersion;
|
||||||
QString recoveryError;
|
QString recoveryError;
|
||||||
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
if (!UpdateTransaction::recoverInterrupted(targetDir, updateDir, &restoredVersion, &recoveryError)) {
|
||||||
QMessageBox::critical(nullptr, "更新恢复失败",
|
QMessageBox::critical(nullptr,
|
||||||
QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").arg(recoveryError));
|
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.")
|
||||||
|
.arg(recoveryError));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
|
if (!restoredVersion.isEmpty() && restoredVersion != fromVersion) {
|
||||||
if (!config.setValue("App", "current_version", restoredVersion)) {
|
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."));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
fromVersion = restoredVersion;
|
fromVersion = restoredVersion;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QProgressDialog progress("正在准备更新...", QString(), 0, 100);
|
QProgressDialog progress(QCoreApplication::translate("Updater", "Preparing update..."), QString(), 0, 100);
|
||||||
progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion));
|
progress.setWindowTitle(QCoreApplication::translate("Updater", "Updating to %1").arg(targetVersion));
|
||||||
progress.setCancelButton(nullptr);
|
progress.setCancelButton(nullptr);
|
||||||
progress.setWindowModality(Qt::ApplicationModal);
|
progress.setWindowModality(Qt::ApplicationModal);
|
||||||
progress.setMinimumDuration(0);
|
progress.setMinimumDuration(0);
|
||||||
@@ -133,8 +161,9 @@ int main(int argc, char* argv[])
|
|||||||
QObject::connect(&logic, &UpdaterLogic::downloadProgress,
|
QObject::connect(&logic, &UpdaterLogic::downloadProgress,
|
||||||
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
|
[&](qint64 received, qint64 total, const QString& path, double bytesPerSecond) {
|
||||||
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
|
const int value = total > 0 ? 30 + static_cast<int>(30 * received / total) : 60;
|
||||||
const QString fileText = path.isEmpty() ? QString("正在准备下载...")
|
const QString fileText = path.isEmpty()
|
||||||
: QString("正在下载:%1").arg(path);
|
? QCoreApplication::translate("Updater", "Preparing download...")
|
||||||
|
: QCoreApplication::translate("Updater", "Downloading: %1").arg(path);
|
||||||
progress.setValue(value);
|
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),
|
.arg(fileText, formatBytes(received), formatBytes(total),
|
||||||
@@ -153,13 +182,18 @@ int main(int argc, char* argv[])
|
|||||||
QMessageBox::critical(nullptr, title, message);
|
QMessageBox::critical(nullptr, title, message);
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
const auto withDetails = [](const QString& message, const QString& details) {
|
||||||
const QString value = config.getValue("Runtime", key).trimmed();
|
return details.trimmed().isEmpty()
|
||||||
return value.isEmpty() ? fallback : value;
|
? message
|
||||||
|
: message + QCoreApplication::translate("Updater", "\n\nDetails:\n%1").arg(details);
|
||||||
};
|
};
|
||||||
const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
|
const auto configuredName = [&](const QString& key, const QString& fallback) {
|
||||||
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
|
return ConfigHelper::executableNameForCurrentPlatform(
|
||||||
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
|
config.getValue("Runtime", key), fallback);
|
||||||
|
};
|
||||||
|
const QString mainExecutable = configuredName("main_executable", "MainApp");
|
||||||
|
const QString updaterExecutable = configuredName("updater_executable", "Updater");
|
||||||
|
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap");
|
||||||
bool timeoutOk = false;
|
bool timeoutOk = false;
|
||||||
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
|
int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk);
|
||||||
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
|
if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000;
|
||||||
@@ -167,19 +201,38 @@ int main(int argc, char* argv[])
|
|||||||
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
|
const QString updaterPath = QDir(runtimeDir).filePath(updaterExecutable);
|
||||||
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
|
const QString bootstrapPath = QDir(runtimeDir).filePath(bootstrapExecutable);
|
||||||
const QString launchToken = config.getValue("App", "launch_token");
|
const QString launchToken = config.getValue("App", "launch_token");
|
||||||
|
QString mainStartupError;
|
||||||
const auto launchMainApp = [&](const QString& healthFile = QString()) {
|
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 ticketPath;
|
||||||
QString ticketError;
|
QString ticketError;
|
||||||
const QString launchVersion = config.getValue("App", "current_version");
|
const QString launchVersion = config.getValue("App", "current_version");
|
||||||
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
|
if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken,
|
||||||
&ticketPath, &ticketError)) {
|
&ticketPath, &ticketError)) {
|
||||||
qDebug() << "Cannot create launch ticket:" << 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;
|
return false;
|
||||||
}
|
}
|
||||||
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
|
QStringList args{QString("--ticket-file=%1").arg(ticketPath)};
|
||||||
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
|
if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile));
|
||||||
const bool started = QProcess::startDetached(mainAppPath, args);
|
const bool started = QProcess::startDetached(mainAppPath, args);
|
||||||
if (!started) QFile::remove(ticketPath);
|
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);
|
||||||
|
}
|
||||||
return started;
|
return started;
|
||||||
};
|
};
|
||||||
const auto bootstrapPlanFile = [&]() {
|
const auto bootstrapPlanFile = [&]() {
|
||||||
@@ -196,7 +249,7 @@ int main(int argc, char* argv[])
|
|||||||
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
const auto delegateRollback = [&](const QString& title, const QString& reason) {
|
||||||
FileHelper::killProcess(mainExecutable);
|
FileHelper::killProcess(mainExecutable);
|
||||||
transaction.markRollbackRequired(reason);
|
transaction.markRollbackRequired(reason);
|
||||||
progress.setLabelText("正在将回滚工作移交给 Bootstrap...");
|
progress.setLabelText(QCoreApplication::translate("Updater", "Delegating rollback to Bootstrap..."));
|
||||||
QApplication::processEvents();
|
QApplication::processEvents();
|
||||||
if (launchBootstrap("rollback")) {
|
if (launchBootstrap("rollback")) {
|
||||||
progress.close();
|
progress.close();
|
||||||
@@ -205,7 +258,7 @@ int main(int argc, char* argv[])
|
|||||||
reportUpdateResult(false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, title,
|
QMessageBox::critical(nullptr, title,
|
||||||
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
|
reason + QCoreApplication::translate("Updater", "\n\nCannot start Bootstrap to perform rollback. Do not continue running the software. Please contact the administrator."));
|
||||||
return -1;
|
return -1;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -214,8 +267,9 @@ int main(int argc, char* argv[])
|
|||||||
if (!transaction.resumeExisting(&resumeError)) {
|
if (!transaction.resumeExisting(&resumeError)) {
|
||||||
reportUpdateResult(false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "事务续办失败",
|
QMessageBox::critical(nullptr,
|
||||||
QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
|
QCoreApplication::translate("Updater", "Transaction Resume Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot read the Bootstrap update transaction: %1").arg(resumeError));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
fromVersion = transaction.fromVersion();
|
fromVersion = transaction.fromVersion();
|
||||||
@@ -227,38 +281,55 @@ int main(int argc, char* argv[])
|
|||||||
reportUpdateResult(false);
|
reportUpdateResult(false);
|
||||||
if (stateOk) launchMainApp();
|
if (stateOk) launchMainApp();
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::warning(nullptr, "更新已回滚",
|
QMessageBox::warning(nullptr,
|
||||||
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。"
|
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."));
|
||||||
return stateOk ? 0 : -1;
|
return stateOk ? 0 : -1;
|
||||||
}
|
}
|
||||||
if (bootstrapResult != "success") {
|
if (bootstrapResult != "success") {
|
||||||
reportUpdateResult(false);
|
reportUpdateResult(false);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::critical(nullptr, "自动回滚失败",
|
QMessageBox::critical(nullptr,
|
||||||
"Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
|
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."));
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
} else if (!transaction.initialize()) {
|
} else if (!transaction.initialize()) {
|
||||||
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed");
|
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");
|
||||||
} else if (!offlinePackagePath.isEmpty()) {
|
} else if (!offlinePackagePath.isEmpty()) {
|
||||||
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
|
QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode"));
|
||||||
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
|
if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8)
|
||||||
return fail("更新准备失败", "无法保存离线事务标记。", "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, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
|
setProgress(resumingFromBootstrap ? 72 : 10,
|
||||||
|
offlinePackagePath.isEmpty()
|
||||||
|
? QCoreApplication::translate("Updater", "Fetching and verifying version manifest...")
|
||||||
|
: QCoreApplication::translate("Updater", "Verifying offline update package..."));
|
||||||
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
|
const QString manifestCacheDir = QDir(updateDir).filePath("manifest_cache");
|
||||||
if (resumingFromBootstrap) {
|
if (resumingFromBootstrap) {
|
||||||
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
if (!logic.loadManifestCache(manifestCacheDir, targetVersion))
|
||||||
return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
|
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||||
|
withDetails(QCoreApplication::translate("Updater", "Cannot read the signed manifest cache after Bootstrap installation."),
|
||||||
|
logic.errorString()));
|
||||||
} else if (offlinePackagePath.isEmpty()) {
|
} else if (offlinePackagePath.isEmpty()) {
|
||||||
logic.getManifest(appId, channel, targetVersion, targetVersionId);
|
logic.getManifest(appId, channel, targetVersion, targetVersionId, releaseId);
|
||||||
}
|
}
|
||||||
if (!logic.verifyManifestSignature()) {
|
if (!logic.verifyManifestSignature()) {
|
||||||
if (resumingFromBootstrap)
|
if (resumingFromBootstrap)
|
||||||
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
|
return delegateRollback(QCoreApplication::translate("Updater", "Security Verification Failed"),
|
||||||
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
|
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");
|
||||||
}
|
}
|
||||||
QStringList obsoletePaths;
|
QStringList obsoletePaths;
|
||||||
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
if (!resumingFromBootstrap && fromVersion != targetVersion) {
|
||||||
@@ -274,45 +345,67 @@ int main(int argc, char* argv[])
|
|||||||
}
|
}
|
||||||
if (!logic.saveManifestCache(manifestCacheDir)) {
|
if (!logic.saveManifestCache(manifestCacheDir)) {
|
||||||
if (resumingFromBootstrap)
|
if (resumingFromBootstrap)
|
||||||
return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。");
|
return delegateRollback(QCoreApplication::translate("Updater", "Manifest Cache Failed"),
|
||||||
return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "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");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resumingFromBootstrap) {
|
if (!resumingFromBootstrap) {
|
||||||
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件...");
|
setProgress(25,
|
||||||
|
offlinePackagePath.isEmpty()
|
||||||
|
? QCoreApplication::translate("Updater", "Fetching secure download URLs...")
|
||||||
|
: QCoreApplication::translate("Updater", "Preparing offline package files..."));
|
||||||
if (offlinePackagePath.isEmpty())
|
if (offlinePackagePath.isEmpty())
|
||||||
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId);
|
||||||
if (logic.getFileList().isEmpty())
|
if (logic.getFileList().isEmpty())
|
||||||
return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list");
|
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");
|
||||||
|
|
||||||
QStorageInfo storage(updateDir);
|
QStorageInfo storage(updateDir);
|
||||||
storage.refresh();
|
storage.refresh();
|
||||||
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
const qint64 requiredBytes = logic.estimateAdditionalDiskBytes(targetDir, obsoletePaths);
|
||||||
const qint64 availableBytes = storage.bytesAvailable();
|
const qint64 availableBytes = storage.bytesAvailable();
|
||||||
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
|
if (!storage.isValid() || !storage.isReady() || availableBytes < requiredBytes) {
|
||||||
return fail("磁盘空间不足",
|
return fail(QCoreApplication::translate("Updater", "Insufficient Disk Space"),
|
||||||
QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n"
|
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.")
|
||||||
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
|
.arg(formatBytes(requiredBytes), formatBytes(qMax<qint64>(0, availableBytes))),
|
||||||
"disk_space_insufficient");
|
"disk_space_insufficient");
|
||||||
}
|
}
|
||||||
|
|
||||||
const QString stagingDir = transaction.stagingDir();
|
const QString stagingDir = transaction.stagingDir();
|
||||||
setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size()));
|
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()));
|
||||||
if (!offlinePackagePath.isEmpty()) {
|
if (!offlinePackagePath.isEmpty()) {
|
||||||
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
|
if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir))
|
||||||
return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid");
|
return fail(QCoreApplication::translate("Updater", "Offline Package Extraction Failed"),
|
||||||
|
logic.offlineError(),
|
||||||
|
"offline_package_invalid");
|
||||||
} else {
|
} else {
|
||||||
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
|
if (!logic.downloadAllFiles(stagingDir, targetDir)) {
|
||||||
logic.reportDownloadResult(appId, channel, targetVersion, false);
|
logic.reportDownloadResult(appId, channel, targetVersion, false);
|
||||||
return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
|
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");
|
||||||
}
|
}
|
||||||
logic.reportDownloadResult(appId, channel, targetVersion, true);
|
logic.reportDownloadResult(appId, channel, targetVersion, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(58, "正在校验完整版本文件...");
|
setProgress(58, QCoreApplication::translate("Updater", "Verifying complete version files..."));
|
||||||
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
if (!logic.validateLocalFiles(stagingDir, targetDir))
|
||||||
return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed");
|
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");
|
||||||
|
|
||||||
QStringList changedPaths;
|
QStringList changedPaths;
|
||||||
QDir stagingRoot(stagingDir);
|
QDir stagingRoot(stagingDir);
|
||||||
@@ -324,24 +417,35 @@ int main(int argc, char* argv[])
|
|||||||
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
|
runtimePrefix.isEmpty() ? bootstrapExecutable : runtimePrefix + "/" + bootstrapExecutable);
|
||||||
for (const QString& path : changedPaths) {
|
for (const QString& path : changedPaths) {
|
||||||
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
if (path.compare(bootstrapManifestPath, Qt::CaseInsensitive) == 0)
|
||||||
return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked");
|
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");
|
||||||
}
|
}
|
||||||
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
|
||||||
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
|
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");
|
||||||
|
|
||||||
setProgress(66, "正在关闭主程序...");
|
setProgress(66, QCoreApplication::translate("Updater", "Closing the main application..."));
|
||||||
if (!FileHelper::killProcess(mainExecutable))
|
if (!FileHelper::killProcess(mainExecutable))
|
||||||
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed");
|
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");
|
||||||
|
|
||||||
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
|
setProgress(72, QCoreApplication::translate("Updater", "Backing up %1 files to be changed, including %2 files to be deleted...")
|
||||||
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
.arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size()));
|
||||||
if (!transaction.backupCurrentFiles())
|
if (!transaction.backupCurrentFiles())
|
||||||
return fail("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "backup_failed");
|
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");
|
||||||
|
|
||||||
const QString planFile = bootstrapPlanFile();
|
const QString planFile = bootstrapPlanFile();
|
||||||
QSaveFile plan(planFile);
|
QSaveFile plan(planFile);
|
||||||
if (!plan.open(QIODevice::WriteOnly))
|
if (!plan.open(QIODevice::WriteOnly))
|
||||||
return fail("接管准备失败", "无法创建 Bootstrap 文件计划。", "bootstrap_plan_failed");
|
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot create the Bootstrap file plan."),
|
||||||
|
"bootstrap_plan_failed");
|
||||||
const auto writePlanItem = [&](char operation, const QString& path) {
|
const auto writePlanItem = [&](char operation, const QString& path) {
|
||||||
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
|
const QByteArray line = QByteArray(1, operation) + '\t' + path.toUtf8() + '\n';
|
||||||
return plan.write(line) == line.size();
|
return plan.write(line) == line.size();
|
||||||
@@ -349,43 +453,58 @@ int main(int argc, char* argv[])
|
|||||||
for (const QString& path : changedPaths) {
|
for (const QString& path : changedPaths) {
|
||||||
if (!writePlanItem('C', path)) {
|
if (!writePlanItem('C', path)) {
|
||||||
plan.cancelWriting();
|
plan.cancelWriting();
|
||||||
return fail("接管准备失败", "无法写入 Bootstrap 复制计划。", "bootstrap_plan_failed");
|
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot write the Bootstrap copy plan."),
|
||||||
|
"bootstrap_plan_failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const QString& path : obsoletePaths) {
|
for (const QString& path : obsoletePaths) {
|
||||||
if (!writePlanItem('D', path)) {
|
if (!writePlanItem('D', path)) {
|
||||||
plan.cancelWriting();
|
plan.cancelWriting();
|
||||||
return fail("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed");
|
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot write the Bootstrap deletion plan."),
|
||||||
|
"bootstrap_plan_failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!plan.commit() || !transaction.markAwaitingBootstrap())
|
if (!plan.commit() || !transaction.markAwaitingBootstrap())
|
||||||
return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed");
|
return fail(QCoreApplication::translate("Updater", "Bootstrap Handoff Preparation Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot commit the Bootstrap file plan or transaction state."),
|
||||||
|
"bootstrap_plan_failed");
|
||||||
|
|
||||||
setProgress(78, "正在将安装工作移交给 Bootstrap...");
|
setProgress(78, QCoreApplication::translate("Updater", "Delegating installation to Bootstrap..."));
|
||||||
if (!launchBootstrap("install"))
|
if (!launchBootstrap("install"))
|
||||||
return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed");
|
return fail(QCoreApplication::translate("Updater", "Bootstrap Startup Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot start the standalone update handoff program: %1").arg(bootstrapPath),
|
||||||
|
"bootstrap_start_failed");
|
||||||
progress.close();
|
progress.close();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(82, "正在校验 Bootstrap 安装结果...");
|
setProgress(82, QCoreApplication::translate("Updater", "Verifying Bootstrap installation result..."));
|
||||||
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
|
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
|
||||||
return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。");
|
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()));
|
||||||
for (const QString& path : transaction.obsoletePaths()) {
|
for (const QString& path : transaction.obsoletePaths()) {
|
||||||
if (QFile::exists(QDir(targetDir).filePath(path)))
|
if (QFile::exists(QDir(targetDir).filePath(path)))
|
||||||
return delegateRollback("废弃文件清理失败", QString("废弃文件仍然存在:%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, "正在保存新版本状态...");
|
setProgress(89, QCoreApplication::translate("Updater", "Saving new version state..."));
|
||||||
if (!config.setValue("App", "current_version", targetVersion)
|
if (!config.setValue("App", "current_version", targetVersion)
|
||||||
|| config.getValue("App", "current_version") != targetVersion)
|
|| config.getValue("App", "current_version") != targetVersion)
|
||||||
return delegateRollback("状态保存失败", "无法保存当前版本号。");
|
return delegateRollback(QCoreApplication::translate("Updater", "State Save Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "Cannot save the current version number."));
|
||||||
|
|
||||||
const QString healthFile = transaction.healthFile();
|
const QString healthFile = transaction.healthFile();
|
||||||
QFile::remove(healthFile);
|
QFile::remove(healthFile);
|
||||||
setProgress(94, "正在启动新版本并等待健康确认...");
|
setProgress(94, QCoreApplication::translate("Updater", "Starting the new version and waiting for health confirmation..."));
|
||||||
if (!launchMainApp(healthFile))
|
if (!launchMainApp(healthFile))
|
||||||
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable));
|
return delegateRollback(QCoreApplication::translate("Updater", "Startup Failed"),
|
||||||
|
mainStartupError.isEmpty()
|
||||||
|
? QCoreApplication::translate("Updater", "%1 cannot be started.").arg(mainExecutable)
|
||||||
|
: mainStartupError);
|
||||||
|
|
||||||
QElapsedTimer healthTimer;
|
QElapsedTimer healthTimer;
|
||||||
healthTimer.start();
|
healthTimer.start();
|
||||||
@@ -394,17 +513,23 @@ int main(int argc, char* argv[])
|
|||||||
QThread::msleep(100);
|
QThread::msleep(100);
|
||||||
}
|
}
|
||||||
if (!QFile::exists(healthFile))
|
if (!QFile::exists(healthFile))
|
||||||
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs));
|
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));
|
||||||
|
|
||||||
setProgress(99, "正在提交更新事务...");
|
setProgress(99, QCoreApplication::translate("Updater", "Committing update transaction..."));
|
||||||
if (!transaction.commit())
|
if (!transaction.commit())
|
||||||
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。");
|
return delegateRollback(QCoreApplication::translate("Updater", "Transaction Commit Failed"),
|
||||||
|
QCoreApplication::translate("Updater", "The new version has started, but the update transaction could not be committed."));
|
||||||
|
|
||||||
QFile::remove(healthFile);
|
QFile::remove(healthFile);
|
||||||
QFile::remove(bootstrapPlanFile());
|
QFile::remove(bootstrapPlanFile());
|
||||||
reportUpdateResult(true);
|
reportUpdateResult(true);
|
||||||
progress.setValue(100);
|
progress.setValue(100);
|
||||||
progress.close();
|
progress.close();
|
||||||
QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
|
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));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"app_id": "simcae",
|
|
||||||
"app_name": "SimCAE",
|
|
||||||
"channel": "stable",
|
|
||||||
"current_version": "1.0.0",
|
|
||||||
"client_protocol": "3",
|
|
||||||
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
|
|
||||||
"license_key": "",
|
|
||||||
"api_base_url": "http://YOUR_SERVER_IP:8000",
|
|
||||||
"client_token": "SimCAEClientToken2026",
|
|
||||||
"request_timeout_ms": "5000",
|
|
||||||
"temp_folder": "update_temp",
|
|
||||||
"device_id": "",
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArX1FSi06eP8XhX4B3Oy6
|
||||||
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
|
FzfkTe3FBIg6OjWpT1541LivRTRt9HXZ30nKuIs5itXBzBQPMU8hsfR0MD9nfPG/
|
||||||
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
|
NlajfZzqbyxAJlUMeThJiwtyz98wv3VE1hS2Bwuc3mbmtfU0zl/MoPdWrluL3x3C
|
||||||
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
|
bt0ylL00rLBuvjgG21zDflVqj3w9CwpKHFsNrSYoI6vOFX9YrRZ9tKcPEkSRPqts
|
||||||
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
|
411QkAQLtMiOMIIhNe5aFIL7doCnglx32hJeHNCTUSxjM9VyHUTEj8wKN/6Gy5iP
|
||||||
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
|
YZGRafeiJZ32RRIHyssereAg3Xe/3scd+tbFNNYP9xrRhRgDacmkSCBodnbJd4Qc
|
||||||
TwIDAQAB
|
rQIDAQAB
|
||||||
-----END PUBLIC KEY-----
|
-----END PUBLIC KEY-----
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"api_base_url": "http://192.168.1.158:18000"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/simcae">
|
||||||
|
<file>server_config.json</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
+176
@@ -0,0 +1,176 @@
|
|||||||
|
客户端国际化说明
|
||||||
|
================
|
||||||
|
|
||||||
|
这份文档说明 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/i18n">
|
||||||
|
<file>update-client_zh_CN.qm</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
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。
|
||||||
@@ -4,8 +4,6 @@ param(
|
|||||||
|
|
||||||
[string]$ReleaseDir = (Get-Location).Path,
|
[string]$ReleaseDir = (Get-Location).Path,
|
||||||
|
|
||||||
[switch]$OverwriteConfig,
|
|
||||||
|
|
||||||
[switch]$IncludeQtRuntime
|
[switch]$IncludeQtRuntime
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,11 +13,8 @@ $sdk = (Resolve-Path $SdkRoot).Path
|
|||||||
$release = (Resolve-Path $ReleaseDir).Path
|
$release = (Resolve-Path $ReleaseDir).Path
|
||||||
|
|
||||||
$binDir = Join-Path $sdk "bin"
|
$binDir = Join-Path $sdk "bin"
|
||||||
$configDir = Join-Path $sdk "config"
|
|
||||||
$appConfig = Join-Path $configDir "app_config.json"
|
|
||||||
$publicKey = Join-Path $configDir "manifest_public_key.pem"
|
|
||||||
|
|
||||||
foreach ($path in @($binDir, $appConfig, $publicKey)) {
|
foreach ($path in @($binDir)) {
|
||||||
if (-not (Test-Path $path)) {
|
if (-not (Test-Path $path)) {
|
||||||
throw "SDK file is missing: $path"
|
throw "SDK file is missing: $path"
|
||||||
}
|
}
|
||||||
@@ -61,14 +56,5 @@ Get-ChildItem $binDir -Force | Where-Object {
|
|||||||
$targetConfigDir = Join-Path $release "config"
|
$targetConfigDir = Join-Path $release "config"
|
||||||
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
New-Item $targetConfigDir -ItemType Directory -Force | Out-Null
|
||||||
|
|
||||||
$targetAppConfig = Join-Path $targetConfigDir "app_config.json"
|
|
||||||
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
|
|
||||||
Copy-Item $appConfig $targetAppConfig -Force
|
|
||||||
} else {
|
|
||||||
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
|
|
||||||
}
|
|
||||||
|
|
||||||
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force
|
|
||||||
|
|
||||||
Write-Host "SDK files installed to: $release"
|
Write-Host "SDK files installed to: $release"
|
||||||
Write-Host "Next: edit config/app_config.json, then start Launcher.exe."
|
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,13 +1,25 @@
|
|||||||
param(
|
param(
|
||||||
[string]$SourceDir = "$PSScriptRoot/out/bin",
|
[string]$SourceDir = "",
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$ConfigFile,
|
[string]$ConfigFile,
|
||||||
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClient",
|
[string]$OutputDir = "",
|
||||||
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip"
|
[string]$ZipFile = "",
|
||||||
|
[switch]$SkipManifestCheck
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$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
|
$source = (Resolve-Path $SourceDir).Path
|
||||||
$config = (Resolve-Path $ConfigFile).Path
|
$config = (Resolve-Path $ConfigFile).Path
|
||||||
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
|
$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
@@ -37,9 +49,27 @@ function Join-RelativePath([string]$Base, [string]$Child) {
|
|||||||
return "$baseNorm/$childNorm"
|
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 = @(
|
$requiredFields = @(
|
||||||
"app_id", "channel", "api_base_url", "current_version",
|
"product_code", "channel", "current_version", "launch_token",
|
||||||
"client_token", "launch_token", "license_key",
|
|
||||||
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
|
"main_executable", "launcher_executable", "updater_executable", "bootstrap_executable"
|
||||||
)
|
)
|
||||||
foreach ($field in $requiredFields) {
|
foreach ($field in $requiredFields) {
|
||||||
@@ -71,7 +101,7 @@ $debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
|
|||||||
$_.Extension -in @('.pdb', '.ilk')
|
$_.Extension -in @('.pdb', '.ilk')
|
||||||
}
|
}
|
||||||
if ($debugArtifacts) {
|
if ($debugArtifacts) {
|
||||||
throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
throw "Source directory contains Debug artifacts. Clean the Release output directory and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
|
||||||
}
|
}
|
||||||
|
|
||||||
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
|
||||||
@@ -85,16 +115,21 @@ if ($duplicateMain) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$manifestName = "manifest_$($settings.current_version).json"
|
$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"
|
$sourceManifestRelative = Join-RelativePath $runtimeDirRelative "update/manifest_cache/$manifestName"
|
||||||
$sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
|
$manifestCandidates = @(
|
||||||
if (-not (Test-Path $sourceManifest)) {
|
(Get-UserDataManifestCandidate $runtimeDirAbsolute $manifestName),
|
||||||
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
|
(Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)),
|
||||||
if (Test-Path $legacySourceManifest) {
|
(Join-Path $source "update/manifest_cache/$manifestName")
|
||||||
$sourceManifest = $legacySourceManifest
|
) | 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))) {
|
||||||
if (-not (Test-Path $sourceManifest)) {
|
$searched = ($manifestCandidates | ForEach-Object { " - $_" }) -join [Environment]::NewLine
|
||||||
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
|
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) {
|
if (Test-Path $OutputDir) {
|
||||||
@@ -126,8 +161,10 @@ Copy-Item $config $outputConfigPath -Force
|
|||||||
}
|
}
|
||||||
|
|
||||||
$manifestDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update/manifest_cache") -replace '/', [IO.Path]::DirectorySeparatorChar)
|
$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
|
New-Item $manifestDir -ItemType Directory -Force | Out-Null
|
||||||
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force
|
||||||
|
}
|
||||||
|
|
||||||
$zipParent = Split-Path $ZipFile -Parent
|
$zipParent = Split-Path $ZipFile -Parent
|
||||||
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
New-Item $zipParent -ItemType Directory -Force | Out-Null
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
#!/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,17 +1,26 @@
|
|||||||
param(
|
param(
|
||||||
[string]$SourceDir = "$PSScriptRoot/out/bin",
|
[string]$SourceDir = "",
|
||||||
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK",
|
[string]$OutputDir = "",
|
||||||
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
|
[string]$ZipFile = "",
|
||||||
[string]$SdkVersion = "0.1.0",
|
[string]$SdkVersion = "0.1.0",
|
||||||
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
|
|
||||||
[switch]$IncludeDemoMainApp,
|
[switch]$IncludeDemoMainApp,
|
||||||
[switch]$IncludeQtRuntime
|
[switch]$IncludeQtRuntime
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$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
|
$source = (Resolve-Path $SourceDir).Path
|
||||||
$exampleConfigPath = (Resolve-Path $ExampleConfig).Path
|
|
||||||
|
|
||||||
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
|
$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe")
|
||||||
foreach ($name in $requiredFiles) {
|
foreach ($name in $requiredFiles) {
|
||||||
@@ -21,16 +30,6 @@ foreach ($name in $requiredFiles) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$publicKeyCandidates = @(
|
|
||||||
(Join-Path $source "config/manifest_public_key.pem"),
|
|
||||||
(Join-Path $source "manifest_public_key.pem"),
|
|
||||||
(Join-Path $PSScriptRoot "config/manifest_public_key.pem")
|
|
||||||
)
|
|
||||||
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
|
|
||||||
if (-not $publicKey) {
|
|
||||||
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key."
|
|
||||||
}
|
|
||||||
|
|
||||||
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
|
$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object {
|
||||||
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
|
$_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or
|
||||||
$_.Extension -in @('.pdb', '.ilk')
|
$_.Extension -in @('.pdb', '.ilk')
|
||||||
@@ -45,7 +44,9 @@ New-Item $OutputDir -ItemType Directory -Force | Out-Null
|
|||||||
$binDir = Join-Path $OutputDir "bin"
|
$binDir = Join-Path $OutputDir "bin"
|
||||||
$configDir = Join-Path $OutputDir "config"
|
$configDir = Join-Path $OutputDir "config"
|
||||||
$scriptsDir = Join-Path $OutputDir "scripts"
|
$scriptsDir = Join-Path $OutputDir "scripts"
|
||||||
New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null
|
$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")
|
$excludedTopLevel = @("config", "update", "update_temp", "manifest_public_key.pem")
|
||||||
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
|
if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" }
|
||||||
@@ -70,6 +71,11 @@ function Test-IsQtRuntimeFile {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
$Item.Name -match '^Qt5.*\.dll$' -or
|
$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 @(
|
$Item.Name -in @(
|
||||||
"libEGL.dll",
|
"libEGL.dll",
|
||||||
"libGLESv2.dll",
|
"libGLESv2.dll",
|
||||||
@@ -83,16 +89,41 @@ Get-ChildItem $source -Force | Where-Object {
|
|||||||
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
|
$_.Name -notin $excludedTopLevel -and -not (Test-IsQtRuntimeFile $_)
|
||||||
} | Copy-Item -Destination $binDir -Recurse -Force
|
} | Copy-Item -Destination $binDir -Recurse -Force
|
||||||
|
|
||||||
Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
|
$commonSourceDir = Join-Path $RepoRoot "Common"
|
||||||
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
|
$commonSourceFiles = @(
|
||||||
|
"ConfigHelper.h",
|
||||||
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object {
|
"ConfigHelper.cpp",
|
||||||
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*"
|
"IntegrityHelper.h",
|
||||||
} | Sort-Object Name | Select-Object -First 1
|
"IntegrityHelper.cpp",
|
||||||
if (-not $wordGuideSource) {
|
"TicketHelper.h",
|
||||||
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory."
|
"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
|
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-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 "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force
|
||||||
@@ -104,6 +135,11 @@ Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "in
|
|||||||
sdk_type = "external-updater-runtime"
|
sdk_type = "external-updater-runtime"
|
||||||
required_entry = "Launcher.exe"
|
required_entry = "Launcher.exe"
|
||||||
contains_demo_main_app = [bool]$IncludeDemoMainApp
|
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
|
} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8
|
||||||
|
|
||||||
$zipParent = Split-Path $ZipFile -Parent
|
$zipParent = Split-Path $ZipFile -Parent
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
#!/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,117 @@
|
|||||||
|
# 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