chore: sync client source after repository split

This commit is contained in:
2026-07-10 02:45:13 +00:00
parent b06e003502
commit cb5819ab7c
35 changed files with 6958 additions and 6958 deletions
+70 -70
View File
@@ -1,70 +1,70 @@
# Operating systems and editors # Operating systems and editors
.DS_Store .DS_Store
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
.vscode/ .vscode/
.idea/ .idea/
.vs/ .vs/
*.suo *.suo
*.user *.user
*.userosscache *.userosscache
*.sln.docstates *.sln.docstates
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
*.bak *.bak
*.swp *.swp
*.log *.log
*~ *~
# Private requirement / handover documents # Private requirement / handover documents
*.pdf *.pdf
*.doc *.doc
*.docx *.docx
# C/C++ generated artifacts # C/C++ generated artifacts
*.obj *.obj
*.o *.o
*.pdb *.pdb
*.ilk *.ilk
*.idb *.idb
*.tlog *.tlog
*.lastbuildstate *.lastbuildstate
*.exp *.exp
*.lib *.lib
*.dll *.dll
*.exe *.exe
# Build outputs # Build outputs
out/ out/
build/ build/
build-*/ build-*/
cmake-build-*/ cmake-build-*/
.cmake/ .cmake/
CMakeFiles/ CMakeFiles/
CMakeCache.txt CMakeCache.txt
CMakeSettings.json CMakeSettings.json
CMakeUserPresets.json CMakeUserPresets.json
Testing/ Testing/
# Third-party/business binary drops and generated packages # Third-party/business binary drops and generated packages
App/ App/
dist/ dist/
*.zip *.zip
*.tar *.tar
*.tar.gz *.tar.gz
*.tgz *.tgz
*.7z *.7z
*.rar *.rar
# Local runtime state and credentials # Local runtime state and credentials
config/app_config.json config/app_config.json
config/client_identity.dat config/client_identity.dat
config/local_state.json config/local_state.json
config/version_policy.dat config/version_policy.dat
config/*.local.json config/*.local.json
config/*private*.pem config/*private*.pem
config/*private*.key config/*private*.key
update/ update/
update_temp/ update_temp/
+6 -6
View File
@@ -1,6 +1,6 @@
project(Bootstrap LANGUAGES CXX) project(Bootstrap LANGUAGES CXX)
add_executable(Bootstrap WIN32 main.cpp) add_executable(Bootstrap WIN32 main.cpp)
target_compile_features(Bootstrap PRIVATE cxx_std_17) target_compile_features(Bootstrap PRIVATE cxx_std_17)
target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE) target_compile_definitions(Bootstrap PRIVATE UNICODE _UNICODE)
target_link_libraries(Bootstrap PRIVATE shell32) target_link_libraries(Bootstrap PRIVATE shell32)
+191 -191
View File
@@ -1,191 +1,191 @@
#include <windows.h> #include <windows.h>
#include <shellapi.h> #include <shellapi.h>
#include <filesystem> #include <filesystem>
#include <chrono> #include <chrono>
#include <cstdlib> #include <cstdlib>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector> #include <vector>
namespace fs = std::filesystem; namespace fs = std::filesystem;
static std::wstring quote(const std::wstring& value) static std::wstring quote(const std::wstring& value)
{ {
std::wstring result = L"\""; std::wstring result = L"\"";
unsigned backslashes = 0; unsigned backslashes = 0;
for (wchar_t ch : value) { for (wchar_t ch : value) {
if (ch == L'\\') { ++backslashes; continue; } if (ch == L'\\') { ++backslashes; continue; }
if (ch == L'\"') { if (ch == L'\"') {
result.append(backslashes * 2 + 1, L'\\'); result.append(backslashes * 2 + 1, L'\\');
result.push_back(L'\"'); result.push_back(L'\"');
backslashes = 0; backslashes = 0;
continue; continue;
} }
result.append(backslashes, L'\\'); result.append(backslashes, L'\\');
backslashes = 0; backslashes = 0;
result.push_back(ch); result.push_back(ch);
} }
result.append(backslashes * 2, L'\\'); result.append(backslashes * 2, L'\\');
result.push_back(L'\"'); result.push_back(L'\"');
return result; return result;
} }
static std::wstring fromUtf8(const std::string& value) static std::wstring fromUtf8(const std::string& value)
{ {
if (value.empty()) return {}; if (value.empty()) return {};
const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), const int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), nullptr, 0); static_cast<int>(value.size()), nullptr, 0);
if (size <= 0) return {}; if (size <= 0) return {};
std::wstring result(size, L'\0'); std::wstring result(size, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(value.size()), result.data(), size); static_cast<int>(value.size()), result.data(), size);
return result; return result;
} }
static bool safeRelative(const fs::path& path) static bool safeRelative(const fs::path& path)
{ {
if (path.empty() || path.is_absolute() || path.has_root_name()) return false; if (path.empty() || path.is_absolute() || path.has_root_name()) return false;
for (const auto& part : path) for (const auto& part : path)
if (part == L"..") return false; if (part == L"..") return false;
return true; return true;
} }
static bool copyWithRetry(const fs::path& source, const fs::path& destination) static bool copyWithRetry(const fs::path& source, const fs::path& destination)
{ {
std::error_code ec; std::error_code ec;
fs::create_directories(destination.parent_path(), ec); fs::create_directories(destination.parent_path(), ec);
for (int i = 0; i < 100; ++i) { for (int i = 0; i < 100; ++i) {
ec.clear(); ec.clear();
fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec); fs::copy_file(source, destination, fs::copy_options::overwrite_existing, ec);
if (!ec) return true; if (!ec) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(100));
} }
return false; return false;
} }
static bool removeWithRetry(const fs::path& path) static bool removeWithRetry(const fs::path& path)
{ {
std::error_code ec; std::error_code ec;
for (int i = 0; i < 100; ++i) { for (int i = 0; i < 100; ++i) {
ec.clear(); ec.clear();
if (!fs::exists(path, ec)) return !ec; if (!fs::exists(path, ec)) return !ec;
if (fs::remove(path, ec)) return true; if (fs::remove(path, ec)) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100)); std::this_thread::sleep_for(std::chrono::milliseconds(100));
} }
return false; return false;
} }
static bool rollback(const fs::path& installDir, const fs::path& backupDir, static bool rollback(const fs::path& installDir, const fs::path& backupDir,
const std::vector<fs::path>& paths) const std::vector<fs::path>& paths)
{ {
bool success = true; bool success = true;
for (const auto& relative : paths) { for (const auto& relative : paths) {
const fs::path destination = installDir / relative; const fs::path destination = installDir / relative;
const fs::path backup = backupDir / relative; const fs::path backup = backupDir / relative;
std::error_code ec; std::error_code ec;
if (fs::exists(backup, ec)) { if (fs::exists(backup, ec)) {
if (!copyWithRetry(backup, destination)) success = false; if (!copyWithRetry(backup, destination)) success = false;
} else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) { } else if (fs::exists(destination, ec) && !fs::remove(destination, ec)) {
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 std::wstring& updater, const std::vector<std::wstring>& updateArgs,
const std::wstring& result) const std::wstring& result)
{ {
std::wstring command = quote(updater); std::wstring command = quote(updater);
for (const auto& arg : updateArgs) command += L" " + quote(arg); for (const auto& arg : updateArgs) command += L" " + quote(arg);
command += L" " + quote(L"--bootstrap-resume=" + result); command += L" " + quote(L"--bootstrap-resume=" + result);
STARTUPINFOW si{}; STARTUPINFOW si{};
si.cb = sizeof(si); si.cb = sizeof(si);
PROCESS_INFORMATION pi{}; PROCESS_INFORMATION pi{};
std::vector<wchar_t> mutableCommand(command.begin(), command.end()); std::vector<wchar_t> mutableCommand(command.begin(), command.end());
mutableCommand.push_back(L'\0'); mutableCommand.push_back(L'\0');
const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr, const BOOL ok = CreateProcessW(updater.c_str(), mutableCommand.data(), nullptr, nullptr,
FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi); FALSE, 0, nullptr, fs::path(updater).parent_path().c_str(), &si, &pi);
if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); } if (ok) { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); }
return ok == TRUE; return ok == TRUE;
} }
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int)
{ {
int argc = 0; int argc = 0;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
if (!argv || argc < 11) { if (!argv || argc < 11) {
MessageBoxW(nullptr, L"Bootstrap 参数不完整。", L"更新接管失败", MB_ICONERROR); MessageBoxW(nullptr, L"Bootstrap 参数不完整。", L"更新接管失败", MB_ICONERROR);
if (argv) LocalFree(argv); if (argv) LocalFree(argv);
return 2; return 2;
} }
const fs::path planFile = argv[1]; const fs::path planFile = argv[1];
const fs::path installDir = argv[2]; const fs::path installDir = argv[2];
const fs::path stagingDir = argv[3]; const fs::path stagingDir = argv[3];
const fs::path backupDir = argv[4]; const fs::path backupDir = argv[4];
const std::wstring updater = argv[5]; const std::wstring updater = argv[5];
const DWORD updaterPid = static_cast<DWORD>(_wtoi(argv[6])); const DWORD updaterPid = static_cast<DWORD>(_wtoi(argv[6]));
std::vector<std::wstring> updateArgs{argv[7], argv[8], argv[9], argv[10]}; std::vector<std::wstring> updateArgs{argv[7], argv[8], argv[9], argv[10]};
const std::wstring mode = argc >= 12 ? argv[11] : L"install"; const std::wstring mode = argc >= 12 ? argv[11] : L"install";
LocalFree(argv); LocalFree(argv);
if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) { if (HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, updaterPid)) {
WaitForSingleObject(process, 30000); WaitForSingleObject(process, 30000);
CloseHandle(process); CloseHandle(process);
} }
struct PlanItem { wchar_t operation; fs::path relative; }; struct PlanItem { wchar_t operation; fs::path relative; };
std::ifstream input(planFile, std::ios::binary); std::ifstream input(planFile, std::ios::binary);
std::vector<PlanItem> items; std::vector<PlanItem> items;
std::vector<fs::path> paths; std::vector<fs::path> paths;
std::string line; std::string line;
while (std::getline(input, line)) { while (std::getline(input, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back(); if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) { if (line.size() < 3 || line[1] != '\t' || (line[0] != 'C' && line[0] != 'D')) {
MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR); MessageBoxW(nullptr, L"更新计划操作格式无效。", L"更新接管失败", MB_ICONERROR);
return 3; return 3;
} }
const fs::path relative(fromUtf8(line.substr(2))); const fs::path relative(fromUtf8(line.substr(2)));
if (!safeRelative(relative)) { if (!safeRelative(relative)) {
MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR); MessageBoxW(nullptr, L"更新计划包含不安全路径。", L"更新接管失败", MB_ICONERROR);
return 3; return 3;
} }
items.push_back({static_cast<wchar_t>(line[0]), relative}); items.push_back({static_cast<wchar_t>(line[0]), relative});
paths.push_back(relative); paths.push_back(relative);
} }
bool success = input.eof(); bool success = input.eof();
bool rolledBack = false; bool rolledBack = false;
fs::path failedPath; fs::path failedPath;
if (mode == L"rollback") { if (mode == L"rollback") {
rolledBack = success && rollback(installDir, backupDir, paths); rolledBack = success && rollback(installDir, backupDir, paths);
success = false; success = false;
} else if (success) { } else if (success) {
for (const auto& item : items) { for (const auto& item : items) {
const fs::path& relative = item.relative; const fs::path& relative = item.relative;
if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) { if (_wcsicmp(relative.filename().c_str(), L"Bootstrap.exe") == 0) {
success = false; success = false;
failedPath = relative; failedPath = relative;
break; break;
} }
const bool itemOk = item.operation == L'C' const bool itemOk = item.operation == L'C'
? copyWithRetry(stagingDir / relative, installDir / relative) ? copyWithRetry(stagingDir / relative, installDir / relative)
: removeWithRetry(installDir / relative); : removeWithRetry(installDir / relative);
if (!itemOk) { if (!itemOk) {
success = false; success = false;
failedPath = relative; failedPath = relative;
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 std::wstring result = success ? L"success" : (rolledBack ? L"rolledback" : L"rollback-failed");
if (!launchUpdater(updater, updateArgs, result)) { if (!launchUpdater(updater, updateArgs, result)) {
std::wstring message = L"无法重新启动 Updater.exe。"; std::wstring message = L"无法重新启动 Updater.exe。";
if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring(); if (!failedPath.empty()) message += L"\n失败文件:" + failedPath.wstring();
MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR); MessageBoxW(nullptr, message.c_str(), L"更新接管失败", MB_ICONERROR);
return 4; return 4;
} }
return (success || rolledBack) ? 0 : 5; return (success || rolledBack) ? 0 : 5;
} }
+69 -69
View File
@@ -1,70 +1,70 @@
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)
add_compile_options(/utf-8) add_compile_options(/utf-8)
endif() endif()
# Qt全局配置 # Qt全局配置
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}) 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 ========== # ========== OpenSSL 手动配置(完全抛弃find_package ==========
set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64") set(OPENSSL_ROOT_DIR "C:/Program Files/OpenSSL-Win64")
set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include") set(OPENSSL_INC "${OPENSSL_ROOT_DIR}/include")
set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd") set(OPENSSL_LIB_DEBUG "${OPENSSL_ROOT_DIR}/lib/VC/x64/MDd")
set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD") set(OPENSSL_LIB_RELEASE "${OPENSSL_ROOT_DIR}/lib/VC/x64/MD")
# 全局宏,所有子项目统一启用OpenSSL # 全局宏,所有子项目统一启用OpenSSL
add_compile_definitions(HAVE_OPENSSL=1) add_compile_definitions(HAVE_OPENSSL=1)
# ========================================================== # ==========================================================
# 统一输出目录 # 统一输出目录
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/out/bin)
# Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库 # Bootstrap 不依赖 Qt,可在 Updater 退出后替换 Updater 与 Qt 运行库
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 # Copy client.ini and config directory to output bin folder
set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json") set(APP_CONFIG_SOURCE_FILE "${CMAKE_SOURCE_DIR}/config/app_config.example.json")
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(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(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("${APP_CONFIG_SOURCE_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(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}
COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config COMMAND ${CMAKE_COMMAND} -E make_directory ${INI_TARGET_FOLDER}/config
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/config/manifest_public_key.pem
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem COMMAND ${CMAKE_COMMAND} -E copy_if_different ${MANIFEST_PUBLIC_KEY_FILE} ${INI_TARGET_FOLDER}/manifest_public_key.pem
COMMENT "Copy client config directory and manifest public key to output bin folder" COMMENT "Copy client config directory and manifest public key to output bin folder"
) )
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)
# Install rules for packaging # Install rules for packaging
if(EXISTS ${CONFIG_SOURCE_DIR}) if(EXISTS ${CONFIG_SOURCE_DIR})
install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config) install(DIRECTORY ${CONFIG_SOURCE_DIR} DESTINATION bin/config)
endif() endif()
if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE}) if(EXISTS ${MANIFEST_PUBLIC_KEY_FILE})
install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin) install(FILES ${MANIFEST_PUBLIC_KEY_FILE} DESTINATION bin)
endif() endif()
+37 -37
View File
@@ -1,38 +1,38 @@
project(Common LANGUAGES C CXX) project(Common LANGUAGES C CXX)
find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets) find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets)
set(SRC set(SRC
HttpHelper.h HttpHelper.h
HttpHelper.cpp HttpHelper.cpp
FileHelper.h FileHelper.h
FileHelper.cpp FileHelper.cpp
ConfigHelper.h ConfigHelper.h
ConfigHelper.cpp ConfigHelper.cpp
PolicyHelper.h PolicyHelper.h
PolicyHelper.cpp PolicyHelper.cpp
LocalStateHelper.h LocalStateHelper.h
LocalStateHelper.cpp LocalStateHelper.cpp
TicketHelper.h TicketHelper.h
TicketHelper.cpp TicketHelper.cpp
IntegrityHelper.h IntegrityHelper.h
IntegrityHelper.cpp IntegrityHelper.cpp
DeviceIdentityHelper.h DeviceIdentityHelper.h
DeviceIdentityHelper.cpp DeviceIdentityHelper.cpp
) )
add_library(Common STATIC ${SRC}) add_library(Common STATIC ${SRC})
# Common编译自身需要OpenSSL头文件 # Common编译自身需要OpenSSL头文件
target_include_directories(Common target_include_directories(Common
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE ${OPENSSL_INC} PRIVATE ${OPENSSL_INC}
) )
# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe # 链接库、库目录通过INTERFACE传递给所有依赖Common的exe
target_link_directories(Common INTERFACE target_link_directories(Common INTERFACE
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}> $<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}> $<$<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 INTERFACE libssl.lib libcrypto.lib
) )
+181 -181
View File
@@ -1,181 +1,181 @@
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QApplication> #include <QApplication>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QSaveFile> #include <QSaveFile>
#include <QSettings> #include <QSettings>
#include <QDebug> #include <QDebug>
ConfigHelper& ConfigHelper::instance() ConfigHelper& ConfigHelper::instance()
{ {
static ConfigHelper obj; static ConfigHelper obj;
return obj; return obj;
} }
ConfigHelper::ConfigHelper() ConfigHelper::ConfigHelper()
{ {
m_configPath = QApplication::applicationDirPath() + "/config/app_config.json"; m_configPath = QApplication::applicationDirPath() + "/config/app_config.json";
migrateLegacyIniIfNeeded(); migrateLegacyIniIfNeeded();
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);
} }
QString ConfigHelper::configPath() const QString ConfigHelper::configPath() const
{ {
return m_configPath; return m_configPath;
} }
QString ConfigHelper::installRoot() const QString ConfigHelper::installRoot() const
{ {
QString relativeRoot = getValue("Runtime", "install_root").trimmed(); QString relativeRoot = getValue("Runtime", "install_root").trimmed();
if (relativeRoot.isEmpty()) if (relativeRoot.isEmpty())
relativeRoot = "."; relativeRoot = ".";
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot)); return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
} }
QString ConfigHelper::runtimeRoot() const QString ConfigHelper::runtimeRoot() const
{ {
return QDir::cleanPath(QApplication::applicationDirPath()); return QDir::cleanPath(QApplication::applicationDirPath());
} }
QString ConfigHelper::updateRoot() const QString ConfigHelper::updateRoot() const
{ {
return QDir(runtimeRoot()).filePath("update"); return QDir(runtimeRoot()).filePath("update");
} }
QString ConfigHelper::runtimeRelativePath() const QString ConfigHelper::runtimeRelativePath() const
{ {
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot())); QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
relative = QDir::cleanPath(relative); relative = QDir::cleanPath(relative);
if (relative == ".") if (relative == ".")
return QString(); return QString();
while (relative.startsWith("./")) while (relative.startsWith("./"))
relative = relative.mid(2); relative = relative.mid(2);
return relative.trimmed(); return relative.trimmed();
} }
QString ConfigHelper::lastError() const QString ConfigHelper::lastError() const
{ {
return m_error; return m_error;
} }
QString ConfigHelper::getValue(const QString& section, const QString& key) const QString ConfigHelper::getValue(const QString& section, const QString& key) const
{ {
Q_UNUSED(section); Q_UNUSED(section);
QFile file(m_configPath); QFile file(m_configPath);
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
return QString(); return QString();
QJsonParseError error; QJsonParseError error;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error); const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &error);
if (error.error != QJsonParseError::NoError || !document.isObject()) if (error.error != QJsonParseError::NoError || !document.isObject())
return QString(); return QString();
return document.object().value(key).toVariant().toString(); return document.object().value(key).toVariant().toString();
} }
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); QFile input(m_configPath);
QJsonObject config; QJsonObject config;
if (input.exists()) if (input.exists())
{ {
if (!input.open(QIODevice::ReadOnly)) if (!input.open(QIODevice::ReadOnly))
{ {
m_error = QString("Cannot open config for reading: %1").arg(input.errorString()); m_error = QString("Cannot open config for reading: %1").arg(input.errorString());
return false; return false;
} }
QJsonParseError parseError; QJsonParseError parseError;
const QByteArray raw = input.readAll(); const QByteArray raw = input.readAll();
input.close(); input.close();
const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError); const QJsonDocument document = QJsonDocument::fromJson(raw, &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject()) if (parseError.error != QJsonParseError::NoError || !document.isObject())
{ {
m_error = QString("Invalid config JSON: %1").arg(parseError.errorString()); m_error = QString("Invalid config JSON: %1").arg(parseError.errorString());
return false; return false;
} }
config = document.object(); config = document.object();
} }
config.insert(key, value); config.insert(key, value);
QDir dir(QFileInfo(m_configPath).path()); QDir dir(QFileInfo(m_configPath).path());
if (!dir.exists() && !dir.mkpath(".")) if (!dir.exists() && !dir.mkpath("."))
{ {
m_error = QString("Cannot create config directory: %1").arg(dir.path()); m_error = QString("Cannot create config directory: %1").arg(dir.path());
return false; return false;
} }
QSaveFile output(m_configPath); QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly)) if (!output.open(QIODevice::WriteOnly))
{ {
m_error = QString("Cannot open config for writing: %1").arg(output.errorString()); m_error = QString("Cannot open config for writing: %1").arg(output.errorString());
return false; return false;
} }
const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented); const QByteArray payload = QJsonDocument(config).toJson(QJsonDocument::Indented);
if (output.write(payload) != payload.size()) if (output.write(payload) != payload.size())
{ {
m_error = QString("Cannot write full config file: %1").arg(output.errorString()); m_error = QString("Cannot write full config file: %1").arg(output.errorString());
output.cancelWriting(); output.cancelWriting();
return false; return false;
} }
if (!output.commit()) if (!output.commit())
{ {
m_error = QString("Cannot commit config file: %1").arg(output.errorString()); m_error = QString("Cannot commit config file: %1").arg(output.errorString());
return false; return false;
} }
return true; return true;
} }
bool ConfigHelper::migrateLegacyIniIfNeeded() bool ConfigHelper::migrateLegacyIniIfNeeded()
{ {
if (QFile::exists(m_configPath)) if (QFile::exists(m_configPath))
return true; return true;
const QString legacyPath = QApplication::applicationDirPath() + "/client.ini"; const QString legacyPath = QApplication::applicationDirPath() + "/client.ini";
if (!QFile::exists(legacyPath)) if (!QFile::exists(legacyPath))
return false; return false;
QSettings ini(legacyPath, QSettings::IniFormat); QSettings ini(legacyPath, QSettings::IniFormat);
QJsonObject config; QJsonObject config;
const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) { const auto copyText = [&](const QString& section, const QString& key, const QString& fallback = QString()) {
const QString value = ini.value(section + "/" + key, fallback).toString(); const QString value = ini.value(section + "/" + key, fallback).toString();
config.insert(key, value); config.insert(key, value);
}; };
copyText("App", "app_id"); copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App"); copyText("App", "app_name", "Marsco Demo App");
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("App", "launch_token");
copyText("License", "license_key"); copyText("License", "license_key");
copyText("Server", "api_base_url"); copyText("Server", "api_base_url");
copyText("Server", "client_token"); copyText("Server", "client_token");
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", "MainApp.exe");
copyText("Runtime", "launcher_executable", "Launcher.exe"); copyText("Runtime", "launcher_executable", "Launcher.exe");
copyText("Runtime", "updater_executable", "Updater.exe"); copyText("Runtime", "updater_executable", "Updater.exe");
copyText("Runtime", "bootstrap_executable", "Bootstrap.exe"); copyText("Runtime", "bootstrap_executable", "Bootstrap.exe");
copyText("Runtime", "health_check_timeout_ms", "15000"); copyText("Runtime", "health_check_timeout_ms", "15000");
config.insert("platform", "windows"); config.insert("platform", "windows");
config.insert("arch", "x64"); config.insert("arch", "x64");
QDir().mkpath(QFileInfo(m_configPath).path()); QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath); QSaveFile output(m_configPath);
if (!output.open(QIODevice::WriteOnly)) if (!output.open(QIODevice::WriteOnly))
return false; return false;
output.write(QJsonDocument(config).toJson(QJsonDocument::Indented)); output.write(QJsonDocument(config).toJson(QJsonDocument::Indented));
const bool saved = output.commit(); const bool saved = output.commit();
if (saved) if (saved)
qDebug() << "Migrated legacy client.ini to" << m_configPath; qDebug() << "Migrated legacy client.ini to" << m_configPath;
return saved; return saved;
} }
+22 -22
View File
@@ -1,22 +1,22 @@
#pragma once #pragma once
#include <QString> #include <QString>
class ConfigHelper class ConfigHelper
{ {
public: public:
static ConfigHelper& instance(); static ConfigHelper& instance();
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 updateRoot() const; QString updateRoot() const;
QString runtimeRelativePath() const; QString runtimeRelativePath() const;
QString lastError() const; QString lastError() const;
private: private:
ConfigHelper(); ConfigHelper();
bool migrateLegacyIniIfNeeded(); bool migrateLegacyIniIfNeeded();
QString m_configPath; QString m_configPath;
QString m_error; QString m_error;
}; };
+49 -49
View File
@@ -1,49 +1,49 @@
#include "DeviceIdentityHelper.h" #include "DeviceIdentityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QCryptographicHash> #include <QCryptographicHash>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QEventLoop> #include <QEventLoop>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QSaveFile> #include <QSaveFile>
#include <QSysInfo> #include <QSysInfo>
#include <QUuid> #include <QUuid>
#include <QTimer> #include <QTimer>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){} DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;} QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;} QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){ bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL #ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false; Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else #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;} 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;} 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; 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 #endif
} }
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){ 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; 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::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){ 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;} m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
const QString trimmedBase=base.trimmed(); const QString trimmedBase=base.trimmed();
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;} 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(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(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(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;} 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(); 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;}} 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}}; 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; 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;
} }
+15 -15
View File
@@ -1,15 +1,15 @@
#pragma once #pragma once
#include <QString> #include <QString>
class DeviceIdentityHelper { class DeviceIdentityHelper {
public: public:
explicit DeviceIdentityHelper(const QString& installDir); explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId, bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey); const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel); bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const; QString deviceId() const;
QString errorString() const; QString errorString() const;
private: private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel); bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64); bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error; QString m_installDir, m_deviceId, m_error;
}; };
+66 -66
View File
@@ -1,67 +1,67 @@
#include "HttpHelper.h" #include "HttpHelper.h"
#include <QNetworkProxy> #include <QNetworkProxy>
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QFile> #include <QFile>
#include <QDir> #include <QDir>
#include <QApplication> #include <QApplication>
#include <QTimer> #include <QTimer>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, void HttpHelper::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)
{ {
QNetworkAccessManager* manager = new QNetworkAccessManager(); QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy); manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url); QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header // Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token"); QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8()); req.setRawHeader("X-Client-Token", token.toUtf8());
QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat")); QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat"));
if (identity.open(QIODevice::ReadOnly)) if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64()); req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ==="; qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url; qDebug() << "Url:" << url;
qDebug() << "Body:" << data; qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data); QNetworkReply* reply = manager->post(req, data);
QEventLoop loop; 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;
QTimer timer; QTimer timer;
timer.setSingleShot(true); timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() { QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) { if (reply && reply->isRunning()) {
qDebug() << "Request timeout, abort:" << url; qDebug() << "Request timeout, abort:" << url;
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(timeoutMs);
loop.exec(); loop.exec();
timer.stop(); timer.stop();
int retCode = 0; int retCode = 0;
QJsonObject retObj; QJsonObject retObj;
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll(); const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) { if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData; qDebug() << "Server raw response:" << respData;
retObj = QJsonDocument::fromJson(respData).object(); retObj = QJsonDocument::fromJson(respData).object();
} }
if (reply->error() != QNetworkReply::NoError) if (reply->error() != QNetworkReply::NoError)
{ {
qDebug() << "Network error code:" << reply->error(); qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString(); qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
} }
callback(retCode, retObj); callback(retCode, retObj);
reply->deleteLater(); reply->deleteLater();
manager->deleteLater(); manager->deleteLater();
} }
+153 -153
View File
@@ -1,153 +1,153 @@
#include "IntegrityHelper.h" #include "IntegrityHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.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 <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QSet> #include <QSet>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
IntegrityHelper::IntegrityHelper(const QString& installDir) IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {} : m_installDir(QDir::cleanPath(installDir)) {}
QString IntegrityHelper::errorString() const { return m_error; } 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)); const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":"); && !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(); const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{ QSet<QString> protectedPaths{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat" "config/client_identity.dat", "config/version_policy.dat"
}; };
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) { if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{ const QStringList runtimeProtected{
"bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat" "config/client_identity.dat", "config/version_policy.dat"
}; };
for (const QString& protectedPath : runtimeProtected) for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath); protectedPaths.insert(runtimePrefix + "/" + protectedPath);
} }
return protectedPaths.contains(p); return protectedPaths.contains(p);
} }
QString IntegrityHelper::sha256(const QString& filePath) const QString IntegrityHelper::sha256(const QString& filePath) const
{ {
QFile file(filePath); QFile file(filePath);
if (!file.open(QIODevice::ReadOnly)) return {}; if (!file.open(QIODevice::ReadOnly)) return {};
QCryptographicHash hash(QCryptographicHash::Sha256); QCryptographicHash hash(QCryptographicHash::Sha256);
while (!file.atEnd()) hash.addData(file.read(1024 * 1024)); while (!file.atEnd()) hash.addData(file.read(1024 * 1024));
return QString::fromLatin1(hash.result().toHex()); return QString::fromLatin1(hash.result().toHex());
} }
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 = "OpenSSL unavailable"; 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 = "manifest public key missing"; 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 = "manifest public key invalid"; 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
&& 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); 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 = "manifest RSA signature invalid";
return ok; return ok;
#endif #endif
} }
bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version) const QString& version)
{ {
m_error.clear(); m_error.clear();
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(
"update/manifest_cache/manifest_" + version + ".json"); "update/manifest_cache/manifest_" + version + ".json");
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 = "signed manifest cache missing for " + version; 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 = "manifest cache JSON invalid"; return false;
} }
const QJsonObject wrapper = wrapperDoc.object(); const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString(); const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) return false; if (manifestText.isEmpty() || signature.isEmpty() || !verifySignature(manifestText, signature)) 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 = "signed manifest payload invalid"; return false;
} }
const QJsonObject manifest = manifestDoc.object(); const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId if (manifest.value("app_id").toString() != 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 = "manifest identity does not match local application"; return false;
} }
QSet<QString> declaredExecutables; QSet<QString> declaredExecutables;
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 = "unsafe manifest path: " + path; return false; }
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 = "required file missing: " + path; 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 = "file hash mismatch: " + path; return false;
} }
const QString suffix = QFileInfo(path).suffix().toCaseFolded(); const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.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);
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));
const QString folded = relative.toCaseFolded(); const QString folded = relative.toCaseFolded();
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded(); const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
const bool runtimeWorkDir = !runtimePrefix.isEmpty() const bool runtimeWorkDir = !runtimePrefix.isEmpty()
&& (folded.startsWith(runtimePrefix + "/update/") && (folded.startsWith(runtimePrefix + "/update/")
|| 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(); const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
m_error = "undeclared executable or plugin: " + relative; return false; m_error = "undeclared executable or plugin: " + relative; return false;
} }
} }
return true; return true;
} }
+20 -20
View File
@@ -1,20 +1,20 @@
#pragma once #pragma once
#include <QString> #include <QString>
class IntegrityHelper class IntegrityHelper
{ {
public: public:
explicit IntegrityHelper(const QString& installDir); explicit IntegrityHelper(const QString& installDir);
bool verifyInstalledVersion(const QString& appId, const QString& channel, bool verifyInstalledVersion(const QString& appId, const QString& channel,
const QString& version); const QString& version);
QString errorString() const; QString errorString() const;
private: private:
bool verifySignature(const QByteArray& payload, const QString& signatureBase64); bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
bool safeRelativePath(const QString& path) const; bool safeRelativePath(const QString& path) const;
bool runtimeProtectedPath(const QString& path) const; bool runtimeProtectedPath(const QString& path) const;
QString sha256(const QString& filePath) const; QString sha256(const QString& filePath) const;
QString m_installDir; QString m_installDir;
QString m_error; QString m_error;
}; };
+147 -147
View File
@@ -1,147 +1,147 @@
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonDocument> #include <QJsonDocument>
#include <QDir> #include <QDir>
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(baseDir + "/config/local_state.json")
{ {
} }
bool LocalStateHelper::loadState(const QString& relativePath) bool LocalStateHelper::loadState(const QString& relativePath)
{ {
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()); QDir dir(QFileInfo(m_filePath).path());
if (!dir.exists() && !dir.mkpath(".")) if (!dir.exists() && !dir.mkpath("."))
{ {
m_error = QString("Cannot create state directory: %1").arg(dir.path()); m_error = QString("Cannot create state directory: %1").arg(dir.path());
return false; 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()},
{"last_online_verified_at", QString()}, {"last_online_verified_at", QString()},
{"last_success_version", QString()} {"last_success_version", QString()}
}; };
m_loaded = true; m_loaded = true;
if (!saveState()) if (!saveState())
{ {
m_error = QString("Cannot write new state file: %1").arg(m_filePath); m_error = QString("Cannot write new state file: %1").arg(m_filePath);
return false; return false;
} }
return true; return true;
} }
if (!file.open(QIODevice::ReadOnly)) if (!file.open(QIODevice::ReadOnly))
{ {
m_error = QString("Cannot open state file: %1").arg(m_filePath); m_error = QString("Cannot open state file: %1").arg(m_filePath);
return false; return false;
} }
QByteArray raw = file.readAll(); QByteArray raw = file.readAll();
file.close(); file.close();
QJsonParseError parseError; QJsonParseError parseError;
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 = QString("Invalid state JSON: %1").arg(parseError.errorString());
return false; return false;
} }
m_state = doc.object(); m_state = doc.object();
m_loaded = true; m_loaded = true;
return true; return true;
} }
bool LocalStateHelper::saveState() const bool LocalStateHelper::saveState() const
{ {
if (!m_loaded) if (!m_loaded)
return false; return false;
QFile file(m_filePath); QFile file(m_filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
{ {
return false; return false;
} }
QJsonDocument doc(m_state); QJsonDocument doc(m_state);
file.write(doc.toJson(QJsonDocument::Indented)); file.write(doc.toJson(QJsonDocument::Indented));
file.close(); file.close();
return true; return true;
} }
bool LocalStateHelper::isLoaded() const bool LocalStateHelper::isLoaded() const
{ {
return m_loaded; return m_loaded;
} }
QString LocalStateHelper::errorString() const QString LocalStateHelper::errorString() const
{ {
return m_error; return m_error;
} }
qint64 LocalStateHelper::maxPolicySeq() const qint64 LocalStateHelper::maxPolicySeq() const
{ {
return m_state.value("max_policy_seq").toVariant().toLongLong(); return m_state.value("max_policy_seq").toVariant().toLongLong();
} }
QDateTime LocalStateHelper::lastSuccessRunAt() const QDateTime LocalStateHelper::lastSuccessRunAt() const
{ {
return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate); return QDateTime::fromString(m_state.value("last_success_run_at").toString(), Qt::ISODate);
} }
QDateTime LocalStateHelper::lastOnlineVerifiedAt() const QDateTime LocalStateHelper::lastOnlineVerifiedAt() const
{ {
return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate); return QDateTime::fromString(m_state.value("last_online_verified_at").toString(), Qt::ISODate);
} }
QString LocalStateHelper::lastSuccessVersion() const QString LocalStateHelper::lastSuccessVersion() const
{ {
return m_state.value("last_success_version").toString(); return m_state.value("last_success_version").toString();
} }
bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const bool LocalStateHelper::isPolicySeqRolledBack(qint64 policySeq) const
{ {
if (!m_loaded) if (!m_loaded)
return false; return false;
return policySeq < maxPolicySeq(); return policySeq < maxPolicySeq();
} }
bool LocalStateHelper::isSystemTimeRewound() const bool LocalStateHelper::isSystemTimeRewound() const
{ {
if (!m_loaded) if (!m_loaded)
return false; return false;
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
const QDateTime lastRun = lastSuccessRunAt(); const QDateTime lastRun = lastSuccessRunAt();
const QDateTime lastOnline = lastOnlineVerifiedAt(); const QDateTime lastOnline = lastOnlineVerifiedAt();
return (lastRun.isValid() && now < lastRun) return (lastRun.isValid() && now < lastRun)
|| (lastOnline.isValid() && now < lastOnline); || (lastOnline.isValid() && now < lastOnline);
} }
void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq) void LocalStateHelper::updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq)
{ {
if (!m_loaded) if (!m_loaded)
m_loaded = true; m_loaded = true;
m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["last_success_run_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["last_success_version"] = currentVersion; m_state["last_success_version"] = currentVersion;
if (policySeq > maxPolicySeq()) if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq; m_state["max_policy_seq"] = policySeq;
} }
void LocalStateHelper::updateOnlineVerified(qint64 policySeq) void LocalStateHelper::updateOnlineVerified(qint64 policySeq)
{ {
if (!m_loaded) if (!m_loaded)
m_loaded = true; m_loaded = true;
m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["last_online_verified_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
if (policySeq > maxPolicySeq()) if (policySeq > maxPolicySeq())
m_state["max_policy_seq"] = policySeq; m_state["max_policy_seq"] = policySeq;
} }
+33 -33
View File
@@ -1,33 +1,33 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QJsonObject> #include <QJsonObject>
#include <QDateTime> #include <QDateTime>
class LocalStateHelper class LocalStateHelper
{ {
public: public:
explicit LocalStateHelper(const QString& baseDir); explicit LocalStateHelper(const QString& baseDir);
bool loadState(const QString& relativePath = "config/local_state.json"); bool loadState(const QString& relativePath = "config/local_state.json");
bool saveState() const; bool saveState() const;
bool isLoaded() const; bool isLoaded() const;
QString errorString() const; QString errorString() const;
qint64 maxPolicySeq() const; qint64 maxPolicySeq() const;
QDateTime lastSuccessRunAt() const; QDateTime lastSuccessRunAt() const;
QDateTime lastOnlineVerifiedAt() const; QDateTime lastOnlineVerifiedAt() const;
QString lastSuccessVersion() const; QString lastSuccessVersion() const;
bool isPolicySeqRolledBack(qint64 policySeq) const; bool isPolicySeqRolledBack(qint64 policySeq) const;
bool isSystemTimeRewound() const; bool isSystemTimeRewound() const;
void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq); void updateAfterSuccessfulRun(const QString& currentVersion, qint64 policySeq);
void updateOnlineVerified(qint64 policySeq); void updateOnlineVerified(qint64 policySeq);
private: private:
QString m_baseDir; QString m_baseDir;
QString m_filePath; QString m_filePath;
QString m_error; QString m_error;
QJsonObject m_state; QJsonObject m_state;
bool m_loaded = false; bool m_loaded = false;
}; };
+116 -116
View File
@@ -1,116 +1,116 @@
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include <QApplication> #include <QApplication>
#include <QDateTime> #include <QDateTime>
#include <QFile> #include <QFile>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <algorithm> #include <algorithm>
#ifdef HAVE_OPENSSL #ifdef HAVE_OPENSSL
#include <openssl/evp.h> #include <openssl/evp.h>
#include <openssl/pem.h> #include <openssl/pem.h>
#endif #endif
PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {} PolicyHelper::PolicyHelper(const QString& baseDir) : m_baseDir(baseDir) {}
bool PolicyHelper::loadPolicy(const QString& relativePath) bool PolicyHelper::loadPolicy(const QString& relativePath)
{ {
QFile file(m_baseDir + "/" + relativePath); QFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; return false; } if (!file.open(QIODevice::ReadOnly)) { m_error = "Cannot open policy file"; 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 = "Invalid policy JSON: " + error.errorString(); return false;
} }
return loadPolicyObject(doc.object()); return loadPolicyObject(doc.object());
} }
bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText) bool PolicyHelper::loadPolicyObject(const QJsonObject& policy, const QString& signedText)
{ {
m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear(); m_policy = policy; m_signedText = signedText; m_loaded = true; m_error.clear();
return isValid(); return isValid();
} }
bool PolicyHelper::savePolicy(const QString& relativePath) const bool PolicyHelper::savePolicy(const QString& relativePath) const
{ {
QSaveFile file(m_baseDir + "/" + relativePath); QSaveFile file(m_baseDir + "/" + relativePath);
if (!file.open(QIODevice::WriteOnly)) return false; 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(); return file.write(bytes) == bytes.size() && file.commit();
} }
QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const QByteArray PolicyHelper::canonicalPolicyBytes(const QJsonObject& policy) const
{ {
QJsonObject copy = policy; copy.remove("signature"); QJsonObject copy = policy; copy.remove("signature");
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject { auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end()); QStringList keys = obj.keys(); std::sort(keys.begin(), keys.end());
QJsonObject sorted; QJsonObject sorted;
for (const QString& key : keys) { for (const QString& key : keys) {
QJsonValue value = obj.value(key); QJsonValue value = obj.value(key);
if (value.isObject()) value = self(self, value.toObject()); if (value.isObject()) value = self(self, value.toObject());
else if (value.isArray()) { else if (value.isArray()) {
QJsonArray array; QJsonArray array;
for (QJsonValue item : value.toArray()) { for (QJsonValue item : value.toArray()) {
if (item.isObject()) item = self(self, item.toObject()); if (item.isObject()) item = self(self, item.toObject());
array.append(item); array.append(item);
} }
value = array; value = array;
} }
sorted.insert(key, value); sorted.insert(key, value);
} }
return sorted; return sorted;
}; };
return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact); return QJsonDocument(sortObject(sortObject, copy)).toJson(QJsonDocument::Compact);
} }
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 = "OpenSSL unavailable"; 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 = "Cannot open policy public key"; 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 = "Invalid policy public key"; 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 = "Invalid RSA policy signature";
return ok; return ok;
#endif #endif
} }
bool PolicyHelper::isValid() const bool PolicyHelper::isValid() const
{ {
if (!m_loaded) return false; if (!m_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.contains(key)) { m_error = "Missing policy field: " + key; return false; }
if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false; if (m_policy.value("signature_alg").toString() != "RSA-2048-SHA256") return false;
const QByteArray payload = m_signedText.isEmpty() ? canonicalPolicyBytes(m_policy) : m_signedText.toUtf8(); 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());
} }
QString PolicyHelper::errorString() const { return m_error; } QString PolicyHelper::errorString() const { return m_error; }
bool PolicyHelper::isVersionAllowed(const QString& version) const { bool PolicyHelper::isVersionAllowed(const QString& version) const {
if (!isValid()) return false; if (!isValid()) return false;
for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false; for (const QJsonValue& v : m_policy.value("disabled_versions").toArray()) if (v.toString() == version) return false;
return true; return true;
} }
bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); } bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); }
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::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);
return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry; return !expiry.isValid() || QDateTime::currentDateTimeUtc() > expiry;
} }
qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; } qint64 PolicyHelper::policySeq() const { return isValid() ? m_policy.value("policy_seq").toVariant().toLongLong() : 0; }
QString PolicyHelper::message() const { return m_policy.value("message").toString(); } QString PolicyHelper::message() const { return m_policy.value("message").toString(); }
+33 -33
View File
@@ -1,33 +1,33 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QJsonObject> #include <QJsonObject>
class PolicyHelper class PolicyHelper
{ {
public: public:
explicit PolicyHelper(const QString& baseDir); explicit PolicyHelper(const QString& baseDir);
bool loadPolicy(const QString& relativePath = "config/version_policy.dat"); bool loadPolicy(const QString& relativePath = "config/version_policy.dat");
bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString()); bool loadPolicyObject(const QJsonObject& policy, const QString& signedText = QString());
bool savePolicy(const QString& relativePath = "config/version_policy.dat") const; bool savePolicy(const QString& relativePath = "config/version_policy.dat") const;
bool isValid() const; bool isValid() const;
QString errorString() const; QString errorString() const;
bool isVersionAllowed(const QString& currentVersion) const; bool isVersionAllowed(const QString& currentVersion) const;
bool allowRun() const; bool allowRun() const;
bool forceUpdate() const; bool forceUpdate() const;
bool allowRollback() const; bool allowRollback() const;
bool isOfflineAllowed() const; bool isOfflineAllowed() const;
bool isExpired() const; bool isExpired() const;
qint64 policySeq() const; qint64 policySeq() const;
QString message() const; QString message() const;
private: private:
QByteArray canonicalPolicyBytes(const QJsonObject& obj) const; QByteArray canonicalPolicyBytes(const QJsonObject& obj) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const; bool verifySignature(const QByteArray& payload, const QString& signatureBase64) const;
QString m_baseDir; QString m_baseDir;
QJsonObject m_policy; QJsonObject m_policy;
QString m_signedText; QString m_signedText;
mutable QString m_error; mutable QString m_error;
bool m_loaded = false; bool m_loaded = false;
}; };
+91 -91
View File
@@ -1,91 +1,91 @@
#include "TicketHelper.h" #include "TicketHelper.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QMessageAuthenticationCode> #include <QMessageAuthenticationCode>
#include <QSaveFile> #include <QSaveFile>
#include <QStandardPaths> #include <QStandardPaths>
#include <QUuid> #include <QUuid>
static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) static QByteArray ticketMac(const QJsonObject& payload, const QString& secret)
{ {
return QMessageAuthenticationCode::hash( return QMessageAuthenticationCode::hash(
QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex(); QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex();
} }
bool TicketHelper::createTicket(const QString& appId, const QString& deviceId, 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()) { if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) {
if (errorMessage) *errorMessage = "ticket identity or secret is empty"; if (errorMessage) *errorMessage = "ticket identity or secret is empty";
return false; return false;
} }
const QDateTime now = QDateTime::currentDateTimeUtc(); const QDateTime now = QDateTime::currentDateTimeUtc();
QJsonObject payload{ QJsonObject payload{
{"app_id", appId}, {"device_id", deviceId}, {"version", version}, {"app_id", appId}, {"device_id", deviceId}, {"version", version},
{"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)}, {"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)},
{"issued_at", now.toString(Qt::ISODate)}, {"issued_at", now.toString(Qt::ISODate)},
{"expires_at", now.addSecs(60).toString(Qt::ISODate)}, {"expires_at", now.addSecs(60).toString(Qt::ISODate)},
{"signature_alg", "HMAC-SHA256"} {"signature_alg", "HMAC-SHA256"}
}; };
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 = "cannot create ticket directory"; 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 = "cannot save ticket";
return false; return false;
} }
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
if (ticketPath) *ticketPath = path; if (ticketPath) *ticketPath = path;
return true; return true;
} }
bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion, const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage) const QString& secret, QString* errorMessage)
{ {
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 = "ticket missing or already consumed";
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 = "cannot read claimed ticket";
return false; return false;
} }
const QByteArray raw = file.readAll(); file.close(); const QByteArray raw = file.readAll(); file.close();
QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。 QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。
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 = "invalid ticket JSON"; 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();
const QByteArray actual = wrapper.value("signature").toString().toLatin1(); const QByteArray actual = wrapper.value("signature").toString().toLatin1();
const QByteArray expected = ticketMac(payload, secret); const QByteArray expected = ticketMac(payload, secret);
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 const bool identityOk = payload.value("app_id").toString() == expectedAppId
&& payload.value("device_id").toString() == expectedDeviceId && payload.value("device_id").toString() == expectedDeviceId
&& payload.value("version").toString() == expectedVersion; && 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 || !identityOk || !timeOk
|| payload.value("nonce").toString().isEmpty()) { || payload.value("nonce").toString().isEmpty()) {
if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid"; if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid";
return false; return false;
} }
return true; return true;
} }
+13 -13
View File
@@ -1,13 +1,13 @@
#pragma once #pragma once
#include <QString> #include <QString>
class TicketHelper class TicketHelper
{ {
public: public:
static bool createTicket(const QString& appId, const QString& deviceId, static bool createTicket(const QString& appId, const QString& deviceId,
const QString& version, const QString& secret, const QString& version, const QString& secret,
QString* ticketPath, QString* errorMessage = nullptr); QString* ticketPath, QString* errorMessage = nullptr);
static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId,
const QString& expectedDeviceId, const QString& expectedVersion, const QString& expectedDeviceId, const QString& expectedVersion,
const QString& secret, QString* errorMessage = nullptr); const QString& secret, QString* errorMessage = nullptr);
}; };
+116 -116
View File
@@ -1,117 +1,117 @@
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include <QDebug> #include <QDebug>
#include <QJsonArray> #include <QJsonArray>
#include <QApplication> #include <QApplication>
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include "LocalStateHelper.h" #include "LocalStateHelper.h"
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 = cfg.getValue("Server", "api_base_url");
m_appId = cfg.getValue("App", "app_id"); m_appId = cfg.getValue("App", "app_id");
m_curVer = cfg.getValue("App", "current_version"); m_curVer = cfg.getValue("App", "current_version");
m_channel = cfg.getValue("App", "channel"); m_channel = cfg.getValue("App", "channel");
// Debug print config // Debug print config
qDebug() << "Read server addr:" << m_serverAddr; qDebug() << "Read server addr:" << m_serverAddr;
qDebug() << "Read app id:" << m_appId; qDebug() << "Read app id:" << m_appId;
} }
void UpdateLogic::checkUpdate() void UpdateLogic::checkUpdate()
{ {
if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty()) if (m_serverAddr.isEmpty() || m_appId.isEmpty() || m_curVer.isEmpty() || m_channel.isEmpty())
{ {
qDebug() << "Config incomplete, abort update check"; qDebug() << "Config incomplete, abort update check";
m_needUpdate = false; m_needUpdate = false;
return; return;
} }
QString url = m_serverAddr + "/api/v1/update/check"; QString url = m_serverAddr + "/api/v1/update/check";
QJsonObject body; QJsonObject body;
body["app_id"] = m_appId; body["app_id"] = m_appId;
body["current_version"] = m_curVer; body["current_version"] = m_curVer;
body["channel"] = m_channel; body["channel"] = m_channel;
const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt(); const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt();
body["client_protocol"] = qMax(3, configuredProtocol); body["client_protocol"] = qMax(3, configuredProtocol);
m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) m_http.postRequest(url, body, [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(); const QString appDir = QApplication::applicationDirPath();
PolicyHelper onlinePolicy(appDir); PolicyHelper onlinePolicy(appDir);
LocalStateHelper state(appDir); LocalStateHelper state(appDir);
const bool stateLoaded = state.loadState(); const bool stateLoaded = state.loadState();
const bool policyValid = onlinePolicy.loadPolicyObject( const bool policyValid = onlinePolicy.loadPolicyObject(
resp.value("policy").toObject(), resp.value("policy_text").toString()); resp.value("policy").toObject(), resp.value("policy_text").toString());
if (!policyValid || !stateLoaded if (!policyValid || !stateLoaded
|| state.isPolicySeqRolledBack(onlinePolicy.policySeq()) || state.isPolicySeqRolledBack(onlinePolicy.policySeq())
|| !onlinePolicy.savePolicy()) || !onlinePolicy.savePolicy())
{ {
qDebug() << "Online policy rejected:" << onlinePolicy.errorString(); qDebug() << "Online policy rejected:" << onlinePolicy.errorString();
m_networkOk = false; m_networkOk = false;
m_needUpdate = false; m_needUpdate = false;
return; return;
} }
state.updateOnlineVerified(onlinePolicy.policySeq()); state.updateOnlineVerified(onlinePolicy.policySeq());
if (!state.saveState()) if (!state.saveState())
{ {
qDebug() << "Cannot persist online policy state"; qDebug() << "Cannot persist online policy state";
m_networkOk = false; m_networkOk = false;
m_needUpdate = false; m_needUpdate = false;
return; return;
} }
m_needUpdate = resp["need_update"].toBool(); m_needUpdate = resp["need_update"].toBool();
m_latestVer = resp["latest_version"].toString(); m_latestVer = resp["latest_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() << "Policy seq:" << onlinePolicy.policySeq();
} }
else else
{ {
m_needUpdate = false; m_needUpdate = false;
qDebug() << "Check update api failed"; 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"; QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body; QJsonObject body;
body["app_id"] = appId; body["app_id"] = appId;
body["channel"] = channel; body["channel"] = channel;
body["version"] = ver; body["version"] = ver;
body["version_id"] = verId; body["version_id"] = verId;
QJsonArray files; QJsonArray files;
body["files"] = files; body["files"] = files;
m_http.postRequest(url, body, [](int code, const QJsonObject& resp) m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{ {
qDebug() << "\n========== File Download Url =========="; qDebug() << "\n========== File Download Url ==========";
qDebug() << resp["files"].toArray(); qDebug() << resp["files"].toArray();
}); });
} }
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"; QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body; QJsonObject body;
body["app_id"] = m_appId; body["app_id"] = m_appId;
body["device_id"] = deviceId; body["device_id"] = deviceId;
body["from_version"] = fromVer; body["from_version"] = fromVer;
body["to_version"] = toVer; body["to_version"] = toVer;
body["result"] = success ? "success" : "fail"; body["result"] = success ? "success" : "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp) m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{ {
qDebug() << "\n========== Update Report Result =========="; qDebug() << "\n========== Update Report Result ==========";
qDebug() << resp; qDebug() << resp;
}); });
} }
+282 -282
View File
@@ -1,282 +1,282 @@
#include <windows.h> #include <windows.h>
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QInputDialog> #include <QInputDialog>
#include <QLineEdit> #include <QLineEdit>
#include <QTextCodec> #include <QTextCodec>
#include "UpdateLogic.h" #include "UpdateLogic.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h" #include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/DeviceIdentityHelper.h" #include "../Common/DeviceIdentityHelper.h"
#include <QFile> #include <QFile>
#include <QFileDialog> #include <QFileDialog>
#include <QDir> #include <QDir>
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(65001); SetConsoleOutputCP(65001);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Launcher"); QApplication::setApplicationName("Marsco Launcher");
QProgressDialog progress("正在检查软件更新...", QString(), 0, 0); QProgressDialog progress("正在检查软件更新...", QString(), 0, 0);
progress.setWindowTitle("Marsco 软件启动器"); progress.setWindowTitle("Marsco 软件启动器");
progress.setCancelButton(nullptr); progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
progress.setAutoClose(false); progress.setAutoClose(false);
progress.show(); progress.show();
QApplication::processEvents(); QApplication::processEvents();
const QString appDir = QApplication::applicationDirPath(); const QString appDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
const auto isLicenseError = [](const QString& errorText) { const auto isLicenseError = [](const QString& errorText) {
const QString text = errorText.toLower(); const QString text = errorText.toLower();
return text.contains("license") return text.contains("license")
|| errorText.contains("授权") || errorText.contains("授权")
|| errorText.contains("过期") || errorText.contains("过期")
|| errorText.contains("设备数量已达到上限") || errorText.contains("设备数量已达到上限")
|| errorText.contains("已绑定其他授权"); || errorText.contains("已绑定其他授权");
}; };
const auto clearDeviceCredential = [&]() { const auto clearDeviceCredential = [&]() {
QFile::remove(QDir(appDir).filePath("config/client_identity.dat")); QFile::remove(QDir(appDir).filePath("config/client_identity.dat"));
config.setValue("Update", "device_id", QString()); config.setValue("Update", "device_id", QString());
}; };
QString licenseKey = config.getValue("License", "license_key").trimmed(); QString licenseKey = config.getValue("License", "license_key").trimmed();
auto promptAndSaveLicense = [&](const QString& reason) -> QString { auto promptAndSaveLicense = [&](const QString& reason) -> QString {
QString promptReason = reason; QString promptReason = reason;
while (true) { while (true) {
progress.close(); progress.close();
bool accepted = false; bool accepted = false;
const QString appName = config.getValue("App", "app_name").trimmed(); const QString appName = config.getValue("App", "app_name").trimmed();
const QString prompt = promptReason.trimmed().isEmpty() const QString prompt = promptReason.trimmed().isEmpty()
? QString("请输入%1授权 License").arg(appName.isEmpty() ? "软件" : appName) ? QString("请输入%1授权 License").arg(appName.isEmpty() ? "软件" : appName)
: QString("%1\n\n请重新输入%2授权 License").arg(promptReason, appName.isEmpty() ? "软件" : appName); : QString("%1\n\n请重新输入%2授权 License").arg(promptReason, appName.isEmpty() ? "软件" : appName);
licenseKey = QInputDialog::getText( licenseKey = QInputDialog::getText(
nullptr, nullptr,
"输入 License", "输入 License",
prompt, prompt,
QLineEdit::Normal, QLineEdit::Normal,
QString(), QString(),
&accepted &accepted
).trimmed(); ).trimmed();
if (!accepted) { if (!accepted) {
QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。"); QMessageBox::information(nullptr, "需要 License", "首次启动需要输入管理员提供的 License。");
return QString(); return QString();
} }
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty()) {
QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。"); QMessageBox::warning(nullptr, "License 不能为空", "请粘贴管理员在后台创建的 License。");
promptReason.clear(); promptReason.clear();
continue; continue;
} }
if (!config.setValue("License", "license_key", licenseKey)) { if (!config.setValue("License", "license_key", licenseKey)) {
QMessageBox::critical(nullptr, "保存 License 失败", QMessageBox::critical(nullptr, "保存 License 失败",
QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError())); QString("无法写入配置文件:%1\n%2").arg(config.configPath(), config.lastError()));
return QString(); return QString();
} }
progress.show(); progress.show();
progress.setLabelText("正在验证 License..."); progress.setLabelText("正在验证 License...");
QApplication::processEvents(); QApplication::processEvents();
return licenseKey; return licenseKey;
} }
}; };
while (true) { while (true) {
if (licenseKey.isEmpty()) { if (licenseKey.isEmpty()) {
licenseKey = promptAndSaveLicense(QString()); licenseKey = promptAndSaveLicense(QString());
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty()) return 0;
} }
DeviceIdentityHelper identity(appDir); DeviceIdentityHelper identity(appDir);
if (identity.ensureIssued(config.getValue("Server", "api_base_url"), if (identity.ensureIssued(config.getValue("Server", "api_base_url"),
config.getValue("Server", "client_token"), config.getValue("Server", "client_token"),
config.getValue("App", "app_id"), config.getValue("App", "app_id"),
config.getValue("App", "channel"), config.getValue("App", "channel"),
licenseKey)) { licenseKey)) {
break; break;
} }
const QString error = identity.errorString(); const QString error = identity.errorString();
if (!isLicenseError(error)) { if (!isLicenseError(error)) {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "设备身份验证失败", error); QMessageBox::critical(nullptr, "设备身份验证失败", error);
return -1; return -1;
} }
clearDeviceCredential(); clearDeviceCredential();
licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error)); licenseKey = promptAndSaveLicense(QString("当前 License 无法使用:%1").arg(error));
if (licenseKey.isEmpty()) return 0; if (licenseKey.isEmpty()) return 0;
} }
UpdateLogic logic; UpdateLogic logic;
logic.checkUpdate(); logic.checkUpdate();
const bool needUpdate = logic.getNeedUpdate(); const bool needUpdate = logic.getNeedUpdate();
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 int targetVersionId = response.value("version_id").toInt();
const QString appId = logic.getAppId(); const QString appId = logic.getAppId();
const QString channel = logic.getChannel(); const QString channel = logic.getChannel();
if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) {
progress.close(); progress.close();
const QJsonValue detail = response.value("detail"); const QJsonValue detail = response.value("detail");
const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString();
const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message; const QString displayMessage = message.isEmpty() ? "设备或 License 授权无效。" : message;
if (isLicenseError(displayMessage)) { if (isLicenseError(displayMessage)) {
clearDeviceCredential(); clearDeviceCredential();
const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage)); const QString newLicense = promptAndSaveLicense(QString("当前授权被服务端拒绝:%1").arg(displayMessage));
if (!newLicense.isEmpty()) { if (!newLicense.isEmpty()) {
progress.close(); progress.close();
QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。"); QMessageBox::information(nullptr, "License 已保存", "请重新启动 Launcher 完成设备授权和更新检查。");
} }
return 0; return 0;
} }
QMessageBox::critical(nullptr, "授权被拒绝", displayMessage); QMessageBox::critical(nullptr, "授权被拒绝", displayMessage);
return -1; return -1;
} }
const QString launchToken = config.getValue("App", "launch_token"); const QString launchToken = config.getValue("App", "launch_token");
const QString currentVersion = config.getValue("App", "current_version"); 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(); const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value; return value.isEmpty() ? fallback : value;
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
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 auto importOfflinePackage = [&]() {
const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)"); const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)");
return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)}); return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)});
}; };
const QString deviceId = config.getValue("Update", "device_id"); const QString deviceId = config.getValue("Update", "device_id");
if (QCoreApplication::arguments().contains("--import-offline")) { if (QCoreApplication::arguments().contains("--import-offline")) {
progress.close(); progress.close();
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。"); if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。");
return 0; return 0;
} }
const auto startMainApp = [&]() { const auto startMainApp = [&]() {
QString ticketPath; QString ticketPath;
QString ticketError; QString ticketError;
if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion,
launchToken, &ticketPath, &ticketError)) { launchToken, &ticketPath, &ticketError)) {
qDebug() << "Cannot create launch ticket:" << ticketError; qDebug() << "Cannot create launch ticket:" << ticketError;
return false; return false;
} }
const bool started = QProcess::startDetached(mainAppPath, const bool started = QProcess::startDetached(mainAppPath,
QStringList{QString("--ticket-file=%1").arg(ticketPath)}); QStringList{QString("--ticket-file=%1").arg(ticketPath)});
if (!started) QFile::remove(ticketPath); if (!started) QFile::remove(ticketPath);
return started; return started;
}; };
progress.setLabelText("正在验证本地运行策略..."); progress.setLabelText("正在验证本地运行策略...");
QApplication::processEvents(); QApplication::processEvents();
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString())); QMessageBox::critical(nullptr, "无法启动", QString("本地版本策略无效:%1").arg(policy.errorString()));
return -1; return -1;
} }
if (policy.isExpired()) if (policy.isExpired())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。"); QMessageBox::critical(nullptr, "无法启动", "本地版本策略已过期,请连接网络或联系管理员。");
return -1; return -1;
} }
if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate)) if ((!policy.allowRun() || !policy.isVersionAllowed(currentVersion)) && !(networkOk && needUpdate))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "当前版本不可运行", QMessageBox::critical(nullptr, "当前版本不可运行",
policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion) policy.message().isEmpty() ? QString("当前版本 %1 已被管理员停用。").arg(currentVersion)
: policy.message()); : policy.message());
return -1; return -1;
} }
LocalStateHelper state(appDir); LocalStateHelper state(appDir);
if (!state.loadState()) if (!state.loadState())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString())); QMessageBox::critical(nullptr, "无法启动", QString("无法读取本地状态:%1").arg(state.errorString()));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq())) if (state.isPolicySeqRolledBack(policy.policySeq()))
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。"); QMessageBox::critical(nullptr, "安全检查失败", "检测到版本策略序列回退,已阻止启动。");
return -1; return -1;
} }
if (state.isSystemTimeRewound()) if (state.isSystemTimeRewound())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。"); QMessageBox::critical(nullptr, "安全检查失败", "检测到系统时间可能被回拨,已阻止启动。");
return -1; return -1;
} }
if (networkOk && needUpdate) if (networkOk && needUpdate)
{ {
progress.close(); progress.close();
const bool rollbackOperation = response.value("action").toString() == "rollback_allowed"; const bool rollbackOperation = response.value("action").toString() == "rollback_allowed";
const QString dialogTitle = rollbackOperation ? "版本回退" const QString dialogTitle = rollbackOperation ? "版本回退"
: (policy.forceUpdate() ? "必须更新" : "发现新版本"); : (policy.forceUpdate() ? "必须更新" : "发现新版本");
const QString prompt = policy.message().isEmpty() const QString prompt = policy.message().isEmpty()
? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?" ? QString(rollbackOperation ? "管理员提供了版本 %1 作为回退目标,是否现在降级?"
: "发现新版本 %1,是否现在更新?").arg(latestVer) : "发现新版本 %1,是否现在更新?").arg(latestVer)
: policy.message() + QString("\n目标版本:%1").arg(latestVer); : policy.message() + QString("\n目标版本:%1").arg(latestVer);
bool accepted = true; bool accepted = true;
if (policy.forceUpdate()) { if (policy.forceUpdate()) {
QMessageBox::information(nullptr, dialogTitle, prompt); QMessageBox::information(nullptr, dialogTitle, prompt);
} else { } else {
accepted = QMessageBox::question(nullptr, dialogTitle, prompt, accepted = QMessageBox::question(nullptr, dialogTitle, prompt,
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; 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, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
return -1; return -1;
} }
return 0; return 0;
} }
const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)}; const QStringList updaterArgs{appId, channel, latestVer, QString::number(targetVersionId)};
if (!QProcess::startDetached(updaterPath, updaterArgs)) if (!QProcess::startDetached(updaterPath, updaterArgs))
{ {
QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath)); QMessageBox::critical(nullptr, "更新器启动失败", QString("无法启动更新器:%1").arg(updaterPath));
return -1; return -1;
} }
return 0; return 0;
} }
if (!networkOk) { if (!networkOk) {
progress.close(); progress.close();
if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?",
QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) {
if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。"); if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。");
return 0; return 0;
} }
progress.show(); progress.show();
} }
if (!networkOk && !policy.isOfflineAllowed()) if (!networkOk && !policy.isOfflineAllowed())
{ {
progress.close(); progress.close();
QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。"); QMessageBox::critical(nullptr, "网络不可用", "无法连接更新服务器,且当前策略不允许离线启动。");
return -1; return -1;
} }
progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); 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, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath));
return -1; return -1;
} }
progress.close(); progress.close();
return 0; return 0;
} }
+130 -130
View File
@@ -1,130 +1,130 @@
#include <Windows.h> #include <Windows.h>
#include <QApplication> #include <QApplication>
#include <QDebug> #include <QDebug>
#include <QTextCodec> #include <QTextCodec>
#include <QMessageBox> #include <QMessageBox>
#include <QDir> #include <QDir>
#include <QFileInfo> #include <QFileInfo>
#include <QSaveFile> #include <QSaveFile>
#include "MainWindow.h" #include "MainWindow.h"
#include "../Common/ConfigHelper.h" #include "../Common/ConfigHelper.h"
#include "../Common/PolicyHelper.h" #include "../Common/PolicyHelper.h"
#include "../Common/LocalStateHelper.h" #include "../Common/LocalStateHelper.h"
#include "../Common/TicketHelper.h" #include "../Common/TicketHelper.h"
#include "../Common/IntegrityHelper.h" #include "../Common/IntegrityHelper.h"
#include "../Common/DeviceIdentityHelper.h" #include "../Common/DeviceIdentityHelper.h"
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(936); SetConsoleOutputCP(936);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK")); QTextCodec::setCodecForLocale(QTextCodec::codecForName("GBK"));
QApplication a(argc, argv); QApplication a(argc, argv);
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("Runtime", "launcher_executable").trimmed();
if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe";
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("--ticket-file="))
ticketFilePath = arg.mid(QString("--ticket-file=").size()); ticketFilePath = arg.mid(QString("--ticket-file=").size());
else if (arg.startsWith("--health-file=")) else if (arg.startsWith("--health-file="))
healthFilePath = arg.mid(QString("--health-file=").size()); healthFilePath = arg.mid(QString("--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"), config.getValue("App", "app_id"), config.getValue("Update", "device_id"),
config.getValue("App", "current_version"), config.getValue("App", "launch_token"), config.getValue("App", "current_version"), config.getValue("App", "launch_token"),
&ticketError)) &ticketError))
{ {
QMessageBox::critical(nullptr, "Startup Restriction", QMessageBox::critical(nullptr, "Startup Restriction",
QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.")
.arg(ticketError, launcherExecutable)); .arg(ticketError, launcherExecutable));
return -1; return -1;
} }
QString appDir = QApplication::applicationDirPath(); QString appDir = QApplication::applicationDirPath();
const QString installRoot = config.installRoot(); const QString installRoot = config.installRoot();
DeviceIdentityHelper identity(appDir); DeviceIdentityHelper identity(appDir);
if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) 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())); QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString()));
return -1; return -1;
} }
PolicyHelper policy(appDir); PolicyHelper policy(appDir);
if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid())
{ {
QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString())); QMessageBox::critical(nullptr, "Policy Error", QString("Local policy invalid: %1").arg(policy.errorString()));
return -1; return -1;
} }
if (policy.isExpired()) if (policy.isExpired())
{ {
QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application."); QMessageBox::critical(nullptr, "Policy Error", "Policy expired, cannot start the application.");
return -1; return -1;
} }
LocalStateHelper state(appDir); LocalStateHelper state(appDir);
if (!state.loadState()) if (!state.loadState())
{ {
QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString())); QMessageBox::critical(nullptr, "State Error", QString("Cannot load local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (state.isPolicySeqRolledBack(policy.policySeq())) if (state.isPolicySeqRolledBack(policy.policySeq()))
{ {
QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked."); QMessageBox::critical(nullptr, "Policy Error", "Detected policy sequence rollback, startup blocked.");
return -1; return -1;
} }
if (state.isSystemTimeRewound()) if (state.isSystemTimeRewound())
{ {
QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked."); QMessageBox::critical(nullptr, "Policy Error", "System time appears to be rewound, startup blocked.");
return -1; return -1;
} }
IntegrityHelper integrity(installRoot); IntegrityHelper integrity(installRoot);
if (!integrity.verifyInstalledVersion( if (!integrity.verifyInstalledVersion(
config.getValue("App", "app_id"), config.getValue("App", "channel"), config.getValue("App", "app_id"), config.getValue("App", "channel"),
config.getValue("App", "current_version"))) config.getValue("App", "current_version")))
{ {
QMessageBox::critical(nullptr, "Integrity Check Failed", QMessageBox::critical(nullptr, "Integrity Check Failed",
QString("Application files failed signed Manifest verification:\n%1") QString("Application files failed signed Manifest verification:\n%1")
.arg(integrity.errorString())); .arg(integrity.errorString()));
return -1; return -1;
} }
MainWindow w; MainWindow w;
w.show(); w.show();
state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq()); state.updateAfterSuccessfulRun(ConfigHelper::instance().getValue("App", "current_version"), policy.policySeq());
if (!state.saveState()) if (!state.saveState())
{ {
QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString())); QMessageBox::critical(nullptr, "State Error", QString("Cannot save local state: %1").arg(state.errorString()));
return -1; return -1;
} }
if (!healthFilePath.isEmpty()) if (!healthFilePath.isEmpty())
{ {
QDir().mkpath(QFileInfo(healthFilePath).path()); QDir().mkpath(QFileInfo(healthFilePath).path());
QSaveFile healthFile(healthFilePath); QSaveFile healthFile(healthFilePath);
const QByteArray healthy("ok\n"); const QByteArray healthy("ok\n");
if (!healthFile.open(QIODevice::WriteOnly) if (!healthFile.open(QIODevice::WriteOnly)
|| healthFile.write(healthy) != healthy.size() || healthFile.write(healthy) != healthy.size()
|| !healthFile.commit()) || !healthFile.commit())
{ {
QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file."); QMessageBox::critical(nullptr, "Startup Error", "Cannot write update health confirmation file.");
return -1; return -1;
} }
} }
qDebug() << "Main program MainApp is running normally"; qDebug() << "Main program MainApp is running normally";
return a.exec(); return a.exec();
} }
+27 -27
View File
@@ -1,27 +1,27 @@
客户端配置说明 客户端配置说明
============== ==============
统一从程序目录下的 config/app_config.json 读取配置。 统一从程序目录下的 config/app_config.json 读取配置。
首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。 首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。
接入新软件时通常需要修改: 接入新软件时通常需要修改:
1. app_id、app_name、channel、current_version。 1. app_id、app_name、channel、current_version。
2. api_base_url、client_token、license_key、launch_token。 2. api_base_url、client_token、license_key、launch_token。
3. main_executable:团队业务主程序文件名。 3. main_executable:团队业务主程序文件名。
4. launcher_executable、updater_executable、bootstrap_executable。 4. launcher_executable、updater_executable、bootstrap_executable。
5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。 5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。
完整格式参考 config/app_config.example.json。 完整格式参考 config/app_config.example.json。
运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。 运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。
Windows 发布打包: Windows 发布打包:
1. 使用 Release 配置编译全部客户端程序。 1. 使用 Release 配置编译全部客户端程序。
2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。 2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。
3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。 3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。
4. 在 PowerShell 执行: 4. 在 PowerShell 执行:
powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json
5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。 5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。
脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。 脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。
+188 -188
View File
@@ -1,188 +1,188 @@
# UpdateClientSDK 接入说明 # UpdateClientSDK 接入说明
这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。 这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。
SDK 核心程序: SDK 核心程序:
- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。 - `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。
- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。 - `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。
- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。 - `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。
- `config/app_config.json`:接入方配置。 - `config/app_config.json`:接入方配置。
- `config/manifest_public_key.pem`:验证服务端签名用的公钥。 - `config/manifest_public_key.pem`:验证服务端签名用的公钥。
## 接入方需要做什么 ## 接入方需要做什么
假设接入的软件叫 `YourApp.exe` 假设接入的软件叫 `YourApp.exe`
1. 在服务端管理后台创建应用,例如 `app_id=your_app_id` 1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`
2. 创建或确认渠道,例如 `stable` 2. 创建或确认渠道,例如 `stable`
3. 创建 License,把生成的 `license_key` 填到客户端配置。 3. 创建 License,把生成的 `license_key` 填到客户端配置。
4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe` 4. 把业务软件完整安装目录作为发布根目录,例如 `SimCAE\`,其中主程序位于 `bin\SimCAE.exe`
5. 把 SDK 的 `Launcher.exe``Updater.exe``Bootstrap.exe` 放到 `SimCAE\bin\` 目录,和 `SimCAE.exe` 同级。不要覆盖 SimCAE 自带的 `Qt5*.dll` 和 Qt 插件目录。 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` 并修改字段。 6.`config/app_config.example.json` 复制成 `SimCAE\bin\config\app_config.json` 并修改字段。
7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。 7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。
8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。 8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。
## 安装目录写权限要求 ## 安装目录写权限要求
当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如: 当前 SDK 会在 `Launcher.exe` 所在目录下写入运行时状态文件。SDK 放在 `SimCAE\bin` 时,这些文件实际位于 `SimCAE\bin` 下,例如:
```text ```text
config/app_config.json config/app_config.json
config/client_identity.dat config/client_identity.dat
config/local_state.json config/local_state.json
config/version_policy.dat config/version_policy.dat
update/ update/
update_temp/ update_temp/
``` ```
所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id` 所以联调和普通运行时,`Launcher.exe` 所在目录必须允许当前 Windows 用户写入。不要直接把联调目录放在 `C:\Program Files\...` 后用普通用户启动;该目录默认禁止普通程序写文件,会导致首次启动报错,例如 `cannot save installation id`
推荐联调目录: 推荐联调目录:
```text ```text
D:\SimCAE_Release\ D:\SimCAE_Release\
C:\Users\<你的用户名>\Desktop\SimCAE_Release\ C:\Users\<你的用户名>\Desktop\SimCAE_Release\
``` ```
如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。 如果最终产品必须安装到 `C:\Program Files\SimCAE\bin`,需要额外设计管理员提权、Windows 服务,或把运行时状态迁移到 `ProgramData` / `AppData`。当前交付版本默认按“安装目录可写”的模式工作。
## SimCAE 目录结构建议 ## SimCAE 目录结构建议
SimCAE 当前安装目录是根目录下有 `bin/``Licenses/``installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录: SimCAE 当前安装目录是根目录下有 `bin/``Licenses/``installerResources/` 等子目录。SDK 推荐放在 `bin/` 目录,和 `SimCAE.exe` 同级;后台发布时仍选择整个安装根目录:
```text ```text
SimCAE/ SimCAE/
bin/ bin/
Launcher.exe Launcher.exe
Updater.exe Updater.exe
Bootstrap.exe Bootstrap.exe
SimCAE.exe SimCAE.exe
config/ config/
app_config.json app_config.json
manifest_public_key.pem manifest_public_key.pem
update/ update/
manifest_cache/ manifest_cache/
Qt5Core.dll Qt5Core.dll
... ...
Licenses/ Licenses/
installerResources/ installerResources/
maintenancetool.exe maintenancetool.exe
``` ```
这种模式下,后台“发布新版本”时选择整个 `SimCAE/` 目录,服务端会检查 `bin/SimCAE.exe` 是否存在,并把整个安装结构写入 Manifest。客户端配置里 `install_root``..`,表示被更新的安装根目录是 `bin` 的上一级;升级事务、下载缓存和 Manifest 缓存仍放在 `SimCAE\bin\update` 这种模式下,后台“发布新版本”时选择整个 `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`,否则会出现“无法定位程序输入点”一类错误。 注意:SimCAE 自己已经带有 Qt 运行库。SDK 的 Launcher/Updater 应使用和 SimCAE 兼容的 Qt 编译,并复用 SimCAE 的 `Qt5*.dll``platforms/``imageformats/` 等目录。不要把另一套 Qt DLL 覆盖到 `SimCAE\bin`,否则会出现“无法定位程序输入点”一类错误。
## app_config.json 关键字段 ## app_config.json 关键字段
```json ```json
{ {
"app_id": "simcae", "app_id": "simcae",
"app_name": "SimCAE", "app_name": "SimCAE",
"channel": "stable", "channel": "stable",
"current_version": "1.0.0", "current_version": "1.0.0",
"client_protocol": "3", "client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "", "license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000", "api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026", "client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000", "request_timeout_ms": "5000",
"temp_folder": "update_temp", "temp_folder": "update_temp",
"device_id": "", "device_id": "",
"install_root": "..", "install_root": "..",
"main_executable": "SimCAE.exe", "main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe", "launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe", "updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe", "bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000", "health_check_timeout_ms": "15000",
"platform": "windows", "platform": "windows",
"arch": "x64" "arch": "x64"
} }
``` ```
字段说明: 字段说明:
- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。 - `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。
- `channel`:发布渠道,例如 `stable``beta``dev` - `channel`:发布渠道,例如 `stable``beta``dev`
- `current_version`:当前客户端初始版本。 - `current_version`:当前客户端初始版本。
- `client_protocol`:客户端协议号,当前建议为 `3` - `client_protocol`:客户端协议号,当前建议为 `3`
- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。 - `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。
- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026` - `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`
- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。 - `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。
- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。 - `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。
- `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..` - `install_root`:安装根目录相对 `Launcher.exe` 所在目录的位置。SDK 放在 `bin` 时填 `..`
- `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe` - `main_executable`:业务主程序相对 `Launcher.exe` 所在目录的路径,SimCAE 默认填 `SimCAE.exe`
- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。 - `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。
## 业务主程序需要配合什么 ## 业务主程序需要配合什么
当前安全模式下,业务主程序需要配合两件事: 当前安全模式下,业务主程序需要配合两件事:
1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。 1. 接收 `--ticket-file=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。 2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。
当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了: 当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了:
- 启动票据校验。 - 启动票据校验。
- 本地 License/设备身份校验。 - 本地 License/设备身份校验。
- 本地策略校验。 - 本地策略校验。
- Manifest 完整性校验。 - Manifest 完整性校验。
- 健康标记写入。 - 健康标记写入。
如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。 如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。
## 如何生成 SDK 包 ## 如何生成 SDK 包
在 Windows PowerShell 中执行: 在 Windows PowerShell 中执行:
```powershell ```powershell
cd client cd client
.\package-sdk.ps1 ` .\package-sdk.ps1 `
-SourceDir .\out\bin ` -SourceDir .\out\bin `
-OutputDir .\dist\UpdateClientSDK ` -OutputDir .\dist\UpdateClientSDK `
-ZipFile .\dist\UpdateClientSDK.zip ` -ZipFile .\dist\UpdateClientSDK.zip `
-SdkVersion 0.1.0 -SdkVersion 0.1.0
``` ```
默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime` 默认生成的 SDK 不包含 Qt 运行库,避免覆盖业务软件自带的 Qt。只有在接入的软件本身不带 Qt,且你确认要让 SDK 自带一套 Qt 运行库时,才额外添加 `-IncludeQtRuntime`
生成结果: 生成结果:
```text ```text
dist/UpdateClientSDK/ dist/UpdateClientSDK/
SimCAE自动升级SDK接入说明_v0.1.docx SimCAE自动升级SDK接入说明_v0.1.docx
sdk_manifest.json sdk_manifest.json
bin/ bin/
config/ config/
app_config.json app_config.json
manifest_public_key.pem manifest_public_key.pem
scripts/ scripts/
``` ```
`UpdateClientSDK.zip` 发给接入方即可。 `UpdateClientSDK.zip` 发给接入方即可。
## 如何生成某个产品的最终客户端包 ## 如何生成某个产品的最终客户端包
SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用: SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用:
```powershell ```powershell
cd client cd client
.\package-client.ps1 ` .\package-client.ps1 `
-SourceDir .\out\bin ` -SourceDir .\out\bin `
-ConfigFile .\config\app_config.json ` -ConfigFile .\config\app_config.json `
-OutputDir .\dist\UpdateClient ` -OutputDir .\dist\UpdateClient `
-ZipFile .\dist\UpdateClient.zip -ZipFile .\dist\UpdateClient.zip
``` ```
`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。 `package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。
## 常见错误 ## 常见错误
1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。 1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。
2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。 2. 首次启动提示 `cannot save installation id`:当前目录不可写,常见于 `C:\Program Files\...`;请换到可写目录联调,或用管理员权限/提权方案。
3. 首次启动设备登记失败:检查 `api_base_url``client_token``license_key`、服务端 License 状态。 3. 首次启动设备登记失败:检查 `api_base_url``client_token``license_key`、服务端 License 状态。
4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。 4. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。
5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。 5. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。
6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。 6. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime` 7. 启动时提示 `无法定位程序输入点 ... Qt5*.dll`:通常是 Qt DLL 被不同版本覆盖或混用。恢复业务软件原始 Qt DLL,并重新打包 SDK;SimCAE 场景下不要使用 `-IncludeQtRuntime`
+41 -41
View File
@@ -1,41 +1,41 @@
# 强制所有编译、设计时生成均使用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路径干扰 # 清除32位Strawberry Perl路径干扰
list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include") 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") 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
main.cpp main.cpp
UpdaterLogic.h UpdaterLogic.h
UpdaterLogic.cpp UpdaterLogic.cpp
UpdateTransaction.h UpdateTransaction.h
UpdateTransaction.cpp UpdateTransaction.cpp
) )
add_executable(Updater ${SRC}) add_executable(Updater ${SRC})
if(WIN32) if(WIN32)
set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE) set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE)
endif() endif()
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到 # 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
target_include_directories(Updater PRIVATE ${OPENSSL_INC}) target_include_directories(Updater PRIVATE ${OPENSSL_INC})
# 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递 # 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递
target_link_libraries(Updater PRIVATE target_link_libraries(Updater PRIVATE
Common Common
Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets
) )
# Qt部署脚本 # Qt部署脚本
if(WIN32) if(WIN32)
get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION)
get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY)
set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe")
add_custom_command(TARGET Updater POST_BUILD add_custom_command(TARGET Updater POST_BUILD
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater> COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
) )
endif() endif()
+274 -274
View File
@@ -1,274 +1,274 @@
#include "UpdateTransaction.h" #include "UpdateTransaction.h"
#include <QDateTime> #include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QSaveFile> #include <QSaveFile>
#include <QSet> #include <QSet>
#include <QUuid> #include <QUuid>
UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, UpdateTransaction::UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId) const QString& toVersion, int manifestId)
: m_installDir(QDir::cleanPath(installDir)), : m_installDir(QDir::cleanPath(installDir)),
m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)), m_updateDir(QDir::cleanPath(updateDir.isEmpty() ? QDir(installDir).filePath("update") : updateDir)),
m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")), m_stateFile(QDir(m_updateDir).filePath("upgrade_state.json")),
m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)), m_transactionId(QUuid::createUuid().toString(QUuid::WithoutBraces)),
m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId) m_fromVersion(fromVersion), m_toVersion(toVersion), m_manifestId(manifestId)
{ {
m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId); m_stagingDir = QDir(m_updateDir).filePath("staging/" + m_transactionId);
m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId); m_backupDir = QDir(m_updateDir).filePath("backup/" + m_transactionId);
} }
bool UpdateTransaction::safeRelativePath(const QString& path) bool UpdateTransaction::safeRelativePath(const QString& path)
{ {
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path)); const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".." return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":"); && !clean.startsWith("../") && !clean.contains(":");
} }
bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const bool UpdateTransaction::copyOverwrite(const QString& source, const QString& destination) const
{ {
if (!QDir().mkpath(QFileInfo(destination).path())) return false; if (!QDir().mkpath(QFileInfo(destination).path())) return false;
if (QFile::exists(destination) && !QFile::remove(destination)) return false; if (QFile::exists(destination) && !QFile::remove(destination)) return false;
return QFile::copy(source, destination); return QFile::copy(source, destination);
} }
bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message) bool UpdateTransaction::writeState(const QString& status, const QString& errorCode, const QString& message)
{ {
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;
m_state["status"] = status; m_state["status"] = status;
m_state["manifest_id"] = m_manifestId; m_state["manifest_id"] = m_manifestId;
m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["updated_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
m_state["staging_dir"] = m_stagingDir; m_state["staging_dir"] = m_stagingDir;
m_state["backup_dir"] = m_backupDir; m_state["backup_dir"] = m_backupDir;
m_state["error_code"] = errorCode; m_state["error_code"] = errorCode;
m_state["message"] = message; m_state["message"] = message;
QJsonArray paths; QJsonArray paths;
for (const QString& path : m_changedPaths) paths.append(path); for (const QString& path : m_changedPaths) paths.append(path);
m_state["changed_paths"] = paths; m_state["changed_paths"] = paths;
QJsonArray obsoletePaths; QJsonArray obsoletePaths;
for (const QString& path : m_obsoletePaths) obsoletePaths.append(path); for (const QString& path : m_obsoletePaths) obsoletePaths.append(path);
m_state["obsolete_paths"] = obsoletePaths; m_state["obsolete_paths"] = obsoletePaths;
QDir().mkpath(m_updateDir); QDir().mkpath(m_updateDir);
QSaveFile file(m_stateFile); QSaveFile file(m_stateFile);
if (!file.open(QIODevice::WriteOnly)) return false; if (!file.open(QIODevice::WriteOnly)) return false;
const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented); const QByteArray payload = QJsonDocument(m_state).toJson(QJsonDocument::Indented);
if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; } if (file.write(payload) != payload.size()) { file.cancelWriting(); return false; }
return file.commit(); return file.commit();
} }
bool UpdateTransaction::initialize() bool UpdateTransaction::initialize()
{ {
QDir(m_stagingDir).removeRecursively(); QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively(); QDir(m_backupDir).removeRecursively();
if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false; if (!QDir().mkpath(m_stagingDir) || !QDir().mkpath(m_backupDir)) return false;
m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); m_state["started_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
return writeState("prepared"); return writeState("prepared");
} }
bool UpdateTransaction::resumeExisting(QString* errorMessage) bool UpdateTransaction::resumeExisting(QString* errorMessage)
{ {
QFile file(m_stateFile); QFile file(m_stateFile);
if (!file.open(QIODevice::ReadOnly)) { if (!file.open(QIODevice::ReadOnly)) {
if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; if (errorMessage) *errorMessage = "cannot open upgrade_state.json";
return false; return false;
} }
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close(); file.close();
if (!doc.isObject()) { if (!doc.isObject()) {
if (errorMessage) *errorMessage = "invalid upgrade_state.json"; if (errorMessage) *errorMessage = "invalid upgrade_state.json";
return false; return false;
} }
m_state = doc.object(); m_state = doc.object();
const QString expectedToVersion = m_toVersion; const QString expectedToVersion = m_toVersion;
const int expectedManifestId = m_manifestId; const int expectedManifestId = m_manifestId;
const QString stateStatus = m_state.value("status").toString(); const QString stateStatus = m_state.value("status").toString();
m_transactionId = m_state.value("transaction_id").toString(); m_transactionId = m_state.value("transaction_id").toString();
m_fromVersion = m_state.value("from_version").toString(); m_fromVersion = m_state.value("from_version").toString();
m_toVersion = m_state.value("to_version").toString(); m_toVersion = m_state.value("to_version").toString();
m_manifestId = m_state.value("manifest_id").toInt(); m_manifestId = m_state.value("manifest_id").toInt();
if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId if (m_toVersion != expectedToVersion || m_manifestId != expectedManifestId
|| (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify" || (stateStatus != "awaiting_bootstrap" && stateStatus != "post_verify"
&& stateStatus != "rollback_required")) { && stateStatus != "rollback_required")) {
if (errorMessage) *errorMessage = "upgrade state does not match resume request"; if (errorMessage) *errorMessage = "upgrade state does not match resume request";
return false; return false;
} }
m_stagingDir = m_state.value("staging_dir").toString(); m_stagingDir = m_state.value("staging_dir").toString();
m_backupDir = m_state.value("backup_dir").toString(); m_backupDir = m_state.value("backup_dir").toString();
m_changedPaths.clear(); m_changedPaths.clear();
m_obsoletePaths.clear(); m_obsoletePaths.clear();
for (const QJsonValue& value : m_state.value("changed_paths").toArray()) { for (const QJsonValue& value : m_state.value("changed_paths").toArray()) {
const QString path = value.toString(); const QString path = value.toString();
if (!safeRelativePath(path)) { if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe path in upgrade state"; if (errorMessage) *errorMessage = "unsafe path in upgrade state";
return false; return false;
} }
m_changedPaths.append(path); m_changedPaths.append(path);
} }
for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) { for (const QJsonValue& value : m_state.value("obsolete_paths").toArray()) {
const QString path = value.toString(); const QString path = value.toString();
if (!safeRelativePath(path)) { if (!safeRelativePath(path)) {
if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state"; if (errorMessage) *errorMessage = "unsafe obsolete path in upgrade state";
return false; return false;
} }
m_obsoletePaths.append(path); m_obsoletePaths.append(path);
} }
if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) { if (m_transactionId.isEmpty() || m_stagingDir.isEmpty() || m_backupDir.isEmpty()) {
if (errorMessage) *errorMessage = "incomplete upgrade state"; if (errorMessage) *errorMessage = "incomplete upgrade state";
return false; return false;
} }
return true; return true;
} }
bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths, bool UpdateTransaction::recordVerifiedFiles(const QStringList& changedPaths,
const QStringList& obsoletePaths) const QStringList& obsoletePaths)
{ {
m_changedPaths.clear(); m_changedPaths.clear();
m_obsoletePaths.clear(); m_obsoletePaths.clear();
QSet<QString> changedKeys; QSet<QString> changedKeys;
for (const QString& path : changedPaths) { for (const QString& path : changedPaths) {
if (!safeRelativePath(path)) return false; if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path); const QString normalized = QDir::fromNativeSeparators(path);
changedKeys.insert(normalized.toCaseFolded()); changedKeys.insert(normalized.toCaseFolded());
m_changedPaths.append(normalized); m_changedPaths.append(normalized);
} }
for (const QString& path : obsoletePaths) { for (const QString& path : obsoletePaths) {
if (!safeRelativePath(path)) return false; if (!safeRelativePath(path)) return false;
const QString normalized = QDir::fromNativeSeparators(path); const QString normalized = QDir::fromNativeSeparators(path);
if (changedKeys.contains(normalized.toCaseFolded())) return false; if (changedKeys.contains(normalized.toCaseFolded())) return false;
m_obsoletePaths.append(normalized); m_obsoletePaths.append(normalized);
} }
return writeState("verified"); return writeState("verified");
} }
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);
for (const QString& path : paths) { for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path); const QString installed = QDir(m_installDir).filePath(path);
if (!QFile::exists(installed)) continue; if (!QFile::exists(installed)) continue;
const QString backup = QDir(m_backupDir).filePath(path); const QString backup = QDir(m_backupDir).filePath(path);
if (!copyOverwrite(installed, backup)) if (!copyOverwrite(installed, backup))
return markFailed("backup_failed", path); return markFailed("backup_failed", path);
} }
return writeState("backed_up"); return writeState("backed_up");
} }
bool UpdateTransaction::installStagedFiles(QString* failedPath) bool UpdateTransaction::installStagedFiles(QString* failedPath)
{ {
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);
const QString destination = QDir(m_installDir).filePath(path); const QString destination = QDir(m_installDir).filePath(path);
if (!copyOverwrite(source, destination)) { if (!copyOverwrite(source, destination)) {
if (failedPath) *failedPath = path; if (failedPath) *failedPath = path;
writeState("rollback_required", "replace_failed", path); writeState("rollback_required", "replace_failed", path);
return false; return false;
} }
} }
return writeState("replaced"); return writeState("replaced");
} }
bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); } bool UpdateTransaction::markAwaitingBootstrap() { return writeState("awaiting_bootstrap"); }
bool UpdateTransaction::markRollbackRequired(const QString& reason) bool UpdateTransaction::markRollbackRequired(const QString& reason)
{ return writeState("rollback_required", "post_install_failed", reason); } { return writeState("rollback_required", "post_install_failed", reason); }
bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); } bool UpdateTransaction::markRolledBack() { return writeState("rolled_back"); }
bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); } bool UpdateTransaction::markPostVerify() { return writeState("post_verify"); }
bool UpdateTransaction::rollback(QString* failedPath) bool UpdateTransaction::rollback(QString* failedPath)
{ {
writeState("rolling_back"); writeState("rolling_back");
bool ok = true; bool ok = true;
QStringList paths = m_changedPaths; QStringList paths = m_changedPaths;
paths.append(m_obsoletePaths); paths.append(m_obsoletePaths);
for (const QString& path : paths) { for (const QString& path : paths) {
const QString installed = QDir(m_installDir).filePath(path); const QString installed = QDir(m_installDir).filePath(path);
const QString backup = QDir(m_backupDir).filePath(path); const QString backup = QDir(m_backupDir).filePath(path);
if (QFile::exists(backup)) { if (QFile::exists(backup)) {
if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; } if (!copyOverwrite(backup, installed)) { ok = false; if (failedPath) *failedPath = path; }
} else if (QFile::exists(installed) && !QFile::remove(installed)) { } else if (QFile::exists(installed) && !QFile::remove(installed)) {
ok = false; if (failedPath) *failedPath = path; ok = false; if (failedPath) *failedPath = path;
} }
} }
writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed", writeState(ok ? "rolled_back" : "failed", ok ? QString() : "rollback_failed",
failedPath ? *failedPath : QString()); failedPath ? *failedPath : QString());
return ok; return ok;
} }
bool UpdateTransaction::commit() bool UpdateTransaction::commit()
{ {
if (!writeState("committed")) return false; if (!writeState("committed")) return false;
QDir(m_stagingDir).removeRecursively(); QDir(m_stagingDir).removeRecursively();
QDir(m_backupDir).removeRecursively(); QDir(m_backupDir).removeRecursively();
return true; return true;
} }
bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message) bool UpdateTransaction::markFailed(const QString& errorCode, const QString& message)
{ return writeState("failed", errorCode, message); } { return writeState("failed", errorCode, message); }
QString UpdateTransaction::transactionId() const { return m_transactionId; } QString UpdateTransaction::transactionId() const { return m_transactionId; }
QString UpdateTransaction::fromVersion() const { return m_fromVersion; } QString UpdateTransaction::fromVersion() const { return m_fromVersion; }
QString UpdateTransaction::stagingDir() const { return m_stagingDir; } QString UpdateTransaction::stagingDir() const { return m_stagingDir; }
QString UpdateTransaction::backupDir() const { return m_backupDir; } QString UpdateTransaction::backupDir() const { return m_backupDir; }
QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; } QStringList UpdateTransaction::obsoletePaths() const { return m_obsoletePaths; }
QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); } QString UpdateTransaction::healthFile() const { return QDir(m_updateDir).filePath("health_" + m_transactionId + ".ok"); }
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");
if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile)) if (!QFile::exists(stateFile) && QFile::exists(legacyStateFile))
stateFile = legacyStateFile; stateFile = legacyStateFile;
QFile file(stateFile); QFile file(stateFile);
if (!file.exists()) return true; if (!file.exists()) return true;
if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; } if (!file.open(QIODevice::ReadOnly)) { if (errorMessage) *errorMessage = "cannot open upgrade_state.json"; return false; }
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close(); file.close();
if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; } if (!doc.isObject()) { if (errorMessage) *errorMessage = "invalid upgrade_state.json"; return false; }
QJsonObject state = doc.object(); QJsonObject state = doc.object();
const QString status = state.value("status").toString(); const QString status = state.value("status").toString();
if (status == "committed") return true; if (status == "committed") return true;
if (status == "rolled_back") { if (status == "rolled_back") {
if (restoredVersion) *restoredVersion = state.value("from_version").toString(); if (restoredVersion) *restoredVersion = state.value("from_version").toString();
return true; return true;
} }
const QString backupDir = state.value("backup_dir").toString(); const QString backupDir = state.value("backup_dir").toString();
QJsonArray paths = state.value("changed_paths").toArray(); QJsonArray paths = state.value("changed_paths").toArray();
for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value); for (const QJsonValue& value : state.value("obsolete_paths").toArray()) paths.append(value);
const QString errorCode = state.value("error_code").toString(); const QString errorCode = state.value("error_code").toString();
const bool mayContainNewFiles = status == "replacing" || status == "replaced" const bool mayContainNewFiles = status == "replacing" || status == "replaced"
|| status == "post_verify" || status == "rollback_required" || status == "post_verify" || status == "rollback_required"
|| status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed"; || status == "awaiting_bootstrap" || status == "rolling_back" || errorCode == "rollback_failed";
bool ok = true; bool ok = true;
for (const QJsonValue& value : paths) { for (const QJsonValue& value : paths) {
const QString path = value.toString(); const QString path = value.toString();
if (!safeRelativePath(path)) { ok = false; continue; } if (!safeRelativePath(path)) { ok = false; continue; }
const QString installed = QDir(installDir).filePath(path); const QString installed = QDir(installDir).filePath(path);
const QString backup = QDir(backupDir).filePath(path); const QString backup = QDir(backupDir).filePath(path);
if (QFile::exists(backup)) { if (QFile::exists(backup)) {
QDir().mkpath(QFileInfo(installed).path()); QDir().mkpath(QFileInfo(installed).path());
if (QFile::exists(installed)) QFile::remove(installed); if (QFile::exists(installed)) QFile::remove(installed);
if (!QFile::copy(backup, installed)) ok = false; if (!QFile::copy(backup, installed)) ok = false;
} else if (mayContainNewFiles) { } else if (mayContainNewFiles) {
if (QFile::exists(installed) && !QFile::remove(installed)) ok = false; if (QFile::exists(installed) && !QFile::remove(installed)) ok = false;
} }
} }
if (restoredVersion) *restoredVersion = state.value("from_version").toString(); if (restoredVersion) *restoredVersion = state.value("from_version").toString();
state["status"] = ok ? "rolled_back" : "failed"; state["status"] = ok ? "rolled_back" : "failed";
QSaveFile output(stateFile); QSaveFile output(stateFile);
if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); } if (output.open(QIODevice::WriteOnly)) { output.write(QJsonDocument(state).toJson(QJsonDocument::Indented)); output.commit(); }
if (!ok && errorMessage) *errorMessage = "failed to restore one or more files"; if (!ok && errorMessage) *errorMessage = "failed to restore one or more files";
return ok; return ok;
} }
+54 -54
View File
@@ -1,54 +1,54 @@
#pragma once #pragma once
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QJsonObject> #include <QJsonObject>
class UpdateTransaction class UpdateTransaction
{ {
public: public:
UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion, UpdateTransaction(const QString& installDir, const QString& updateDir, const QString& fromVersion,
const QString& toVersion, int manifestId); const QString& toVersion, int manifestId);
bool initialize(); bool initialize();
bool resumeExisting(QString* errorMessage = nullptr); bool resumeExisting(QString* errorMessage = nullptr);
bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths); bool recordVerifiedFiles(const QStringList& changedPaths, const QStringList& obsoletePaths);
bool backupCurrentFiles(); bool backupCurrentFiles();
bool installStagedFiles(QString* failedPath = nullptr); bool installStagedFiles(QString* failedPath = nullptr);
bool markAwaitingBootstrap(); bool markAwaitingBootstrap();
bool markRollbackRequired(const QString& reason); bool markRollbackRequired(const QString& reason);
bool markRolledBack(); bool markRolledBack();
bool markPostVerify(); bool markPostVerify();
bool rollback(QString* failedPath = nullptr); bool rollback(QString* failedPath = nullptr);
bool commit(); bool commit();
bool markFailed(const QString& errorCode, const QString& message); bool markFailed(const QString& errorCode, const QString& message);
QString transactionId() const; QString transactionId() const;
QString fromVersion() const; QString fromVersion() const;
QString stagingDir() const; QString stagingDir() const;
QString backupDir() const; QString backupDir() const;
QStringList obsoletePaths() const; QStringList obsoletePaths() const;
QString healthFile() const; QString healthFile() const;
static bool recoverInterrupted(const QString& installDir, const QString& updateDir, static bool recoverInterrupted(const QString& installDir, const QString& updateDir,
QString* restoredVersion = nullptr, QString* restoredVersion = nullptr,
QString* errorMessage = nullptr); QString* errorMessage = nullptr);
private: private:
bool writeState(const QString& status, const QString& errorCode = QString(), bool writeState(const QString& status, const QString& errorCode = QString(),
const QString& message = QString()); const QString& message = QString());
bool copyOverwrite(const QString& source, const QString& destination) const; bool copyOverwrite(const QString& source, const QString& destination) const;
static bool safeRelativePath(const QString& path); static bool safeRelativePath(const QString& path);
QString m_installDir; QString m_installDir;
QString m_updateDir; QString m_updateDir;
QString m_stateFile; QString m_stateFile;
QString m_transactionId; QString m_transactionId;
QString m_fromVersion; QString m_fromVersion;
QString m_toVersion; QString m_toVersion;
int m_manifestId = 0; int m_manifestId = 0;
QString m_stagingDir; QString m_stagingDir;
QString m_backupDir; QString m_backupDir;
QStringList m_changedPaths; QStringList m_changedPaths;
QStringList m_obsoletePaths; QStringList m_obsoletePaths;
QJsonObject m_state; QJsonObject m_state;
}; };
+751 -751
View File
File diff suppressed because it is too large Load Diff
+72 -72
View File
@@ -1,73 +1,73 @@
#pragma once #pragma once
#include <QObject> #include <QObject>
#include "HttpHelper.h" #include "HttpHelper.h"
#include "FileHelper.h" #include "FileHelper.h"
#include <QJsonObject> #include <QJsonObject>
#include <QJsonArray> #include <QJsonArray>
#include <QJsonDocument> #include <QJsonDocument>
#include <QStringList> #include <QStringList>
#include <QNetworkReply> #include <QNetworkReply>
#include <QMap> #include <QMap>
#include <QElapsedTimer> #include <QElapsedTimer>
struct FileDownloadItem struct FileDownloadItem
{ {
QString path; QString path;
QString url; QString url;
QString sha256; QString sha256;
qint64 size; qint64 size;
}; };
class UpdaterLogic : public QObject class UpdaterLogic : public QObject
{ {
Q_OBJECT Q_OBJECT
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);
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; }
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);
void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success); void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success);
bool downloadAllFiles(const QString& tempDir, const QString& targetDir); bool downloadAllFiles(const QString& tempDir, const QString& targetDir);
qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const; qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const;
QString calcLocalFileSha256(const QString& filePath) const; QString calcLocalFileSha256(const QString& filePath) const;
QList<FileDownloadItem> getFileList() const; QList<FileDownloadItem> getFileList() const;
QJsonObject getManifest() const { return m_manifest; } QJsonObject getManifest() const { return m_manifest; }
QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const; QStringList obsoleteFilesComparedTo(const QJsonObject& oldManifest) const;
QString getManifestText() const { return m_manifestText; } QString getManifestText() const { return m_manifestText; }
bool allDownloadSuccess() const { return m_downloadAllOk; } bool allDownloadSuccess() const { return m_downloadAllOk; }
signals: signals:
void fetchUrlFinished(); void fetchUrlFinished();
void downloadProgress(qint64 receivedBytes, qint64 totalBytes, void downloadProgress(qint64 receivedBytes, qint64 totalBytes,
const QString& currentPath, double bytesPerSecond); const QString& currentPath, double bytesPerSecond);
private: private:
bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256, bool downloadSingleFile(const QString& url, const QString& savePath, const QString& expectSha256,
qint64 expectedSize, const QString& resumePartPath); qint64 expectedSize, const QString& resumePartPath);
bool isSafeRelativePath(const QString& path) const; bool isSafeRelativePath(const QString& path) const;
bool isRuntimeProtectedPath(const QString& path) const; bool isRuntimeProtectedPath(const QString& path) const;
QByteArray canonicalManifestBytes(const QJsonObject& manifest) const; QByteArray canonicalManifestBytes(const QJsonObject& manifest) const;
bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const; bool verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const;
HttpHelper m_http; HttpHelper m_http;
QString m_serverAddr; QString m_serverAddr;
QJsonObject m_manifest; QJsonObject m_manifest;
QString m_manifestText; QString m_manifestText;
QList<FileDownloadItem> m_fileItems; QList<FileDownloadItem> m_fileItems;
bool m_downloadAllOk = false; bool m_downloadAllOk = false;
qint64 m_downloadTotalBytes = 0; qint64 m_downloadTotalBytes = 0;
qint64 m_downloadCompletedBytes = 0; qint64 m_downloadCompletedBytes = 0;
qint64 m_sessionDownloadedBytes = 0; qint64 m_sessionDownloadedBytes = 0;
QString m_currentDownloadPath; QString m_currentDownloadPath;
QString m_offlineError; QString m_offlineError;
QElapsedTimer m_downloadTimer; QElapsedTimer m_downloadTimer;
}; };
+410 -410
View File
@@ -1,410 +1,410 @@
#include <windows.h> #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 <QMessageBox> #include <QMessageBox>
#include <QProcess> #include <QProcess>
#include <QProgressDialog> #include <QProgressDialog>
#include <QSaveFile> #include <QSaveFile>
#include <QStorageInfo> #include <QStorageInfo>
#include <QTextCodec> #include <QTextCodec>
#include <QThread> #include <QThread>
#include "UpdaterLogic.h" #include "UpdaterLogic.h"
#include "UpdateTransaction.h" #include "UpdateTransaction.h"
#include "FileHelper.h" #include "FileHelper.h"
#include "ConfigHelper.h" #include "ConfigHelper.h"
#include "TicketHelper.h" #include "TicketHelper.h"
#include "PolicyHelper.h" #include "PolicyHelper.h"
#include <QVersionNumber> #include <QVersionNumber>
int main(int argc, char* argv[]) int main(int argc, char* argv[])
{ {
SetConsoleOutputCP(65001); SetConsoleOutputCP(65001);
QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8")); QTextCodec::setCodecForLocale(QTextCodec::codecForName("UTF-8"));
QApplication app(argc, argv); QApplication app(argc, argv);
QApplication::setApplicationName("Marsco Updater"); QApplication::setApplicationName("Marsco Updater");
UpdaterLogic logic; UpdaterLogic logic;
QString offlinePackagePath; QString offlinePackagePath;
QString appId; QString appId;
QString channel; QString channel;
QString targetVersion; QString targetVersion;
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, "离线包无效", logic.offlineError());
return -1; return -1;
} }
const QJsonObject packageManifest = logic.getManifest(); const QJsonObject packageManifest = logic.getManifest();
appId = packageManifest.value("app_id").toString(); appId = packageManifest.value("app_id").toString();
channel = packageManifest.value("channel").toString(); channel = packageManifest.value("channel").toString();
targetVersion = packageManifest.value("version").toString(); targetVersion = packageManifest.value("version").toString();
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, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。");
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();
} }
QString bootstrapResult; QString bootstrapResult;
for (int i = 5; i < argc; ++i) { for (int i = 5; i < argc; ++i) {
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());
} }
const bool resumingFromBootstrap = !bootstrapResult.isEmpty(); const bool resumingFromBootstrap = !bootstrapResult.isEmpty();
const QString runtimeDir = QApplication::applicationDirPath(); const QString runtimeDir = QApplication::applicationDirPath();
ConfigHelper& config = ConfigHelper::instance(); ConfigHelper& config = ConfigHelper::instance();
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")) { if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) {
QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。");
return -1; return -1;
} }
QString fromVersion = config.getValue("App", "current_version"); QString fromVersion = config.getValue("App", "current_version");
if (!offlinePackagePath.isEmpty()) { if (!offlinePackagePath.isEmpty()) {
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, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。");
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, "禁止降级", "当前签名策略不允许安装较低版本的离线包。");
return -1; return -1;
} }
} }
QString deviceId = config.getValue("Update", "device_id"); QString deviceId = config.getValue("Update", "device_id");
if (deviceId.isEmpty()) deviceId = "unknown_device"; if (deviceId.isEmpty()) deviceId = "unknown_device";
if (!resumingFromBootstrap) { if (!resumingFromBootstrap) {
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)); QString("检测到上次更新未完成,但无法恢复旧版本:%1\n请不要继续运行软件,并联系管理员。").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, "更新恢复失败", "旧文件已经恢复,但无法恢复版本状态,请检查配置目录写入权限。");
return -1; return -1;
} }
fromVersion = restoredVersion; fromVersion = restoredVersion;
} }
} }
QProgressDialog progress("正在准备更新...", QString(), 0, 100); QProgressDialog progress("正在准备更新...", QString(), 0, 100);
progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion)); progress.setWindowTitle(QString("正在更新到 %1").arg(targetVersion));
progress.setCancelButton(nullptr); progress.setCancelButton(nullptr);
progress.setWindowModality(Qt::ApplicationModal); progress.setWindowModality(Qt::ApplicationModal);
progress.setMinimumDuration(0); progress.setMinimumDuration(0);
progress.setAutoClose(false); progress.setAutoClose(false);
progress.setValue(2); progress.setValue(2);
progress.show(); progress.show();
QApplication::processEvents(); QApplication::processEvents();
UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId); UpdateTransaction transaction(targetDir, updateDir, fromVersion, targetVersion, targetVersionId);
const auto setProgress = [&](int value, const QString& message) { const auto setProgress = [&](int value, const QString& message) {
progress.setValue(value); progress.setValue(value);
progress.setLabelText(message); progress.setLabelText(message);
QApplication::processEvents(); QApplication::processEvents();
}; };
const auto formatBytes = [](qint64 bytes) { const auto formatBytes = [](qint64 bytes) {
const double value = static_cast<double>(bytes); const double value = static_cast<double>(bytes);
if (bytes >= 1024LL * 1024 * 1024) if (bytes >= 1024LL * 1024 * 1024)
return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB"; return QString::number(value / (1024.0 * 1024 * 1024), 'f', 2) + " GB";
if (bytes >= 1024LL * 1024) if (bytes >= 1024LL * 1024)
return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB"; return QString::number(value / (1024.0 * 1024), 'f', 2) + " MB";
if (bytes >= 1024) if (bytes >= 1024)
return QString::number(value / 1024.0, 'f', 1) + " KB"; return QString::number(value / 1024.0, 'f', 1) + " KB";
return QString::number(bytes) + " B"; return QString::number(bytes) + " B";
}; };
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("正在准备下载...")
: QString("正在下载:%1").arg(path); : QString("正在下载:%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),
formatBytes(static_cast<qint64>(bytesPerSecond)))); formatBytes(static_cast<qint64>(bytesPerSecond))));
QApplication::processEvents(); QApplication::processEvents();
}); });
const auto reportUpdateResult = [&](bool success) { const auto reportUpdateResult = [&](bool success) {
if (offlinePackagePath.isEmpty()) if (offlinePackagePath.isEmpty())
logic.reportResult(deviceId, fromVersion, targetVersion, success); logic.reportResult(deviceId, fromVersion, targetVersion, success);
}; };
const auto fail = [&](const QString& title, const QString& message, const auto fail = [&](const QString& title, const QString& message,
const QString& errorCode = QString("update_failed")) { const QString& errorCode = QString("update_failed")) {
transaction.markFailed(errorCode, message); transaction.markFailed(errorCode, message);
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
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 configuredName = [&](const QString& key, const QString& fallback) {
const QString value = config.getValue("Runtime", key).trimmed(); const QString value = config.getValue("Runtime", key).trimmed();
return value.isEmpty() ? fallback : value; return value.isEmpty() ? fallback : value;
}; };
const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); const QString mainExecutable = configuredName("main_executable", "MainApp.exe");
const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); const QString updaterExecutable = configuredName("updater_executable", "Updater.exe");
const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe"); const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe");
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;
const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable); const QString mainAppPath = QDir(runtimeDir).filePath(mainExecutable);
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");
const auto launchMainApp = [&](const QString& healthFile = QString()) { const auto launchMainApp = [&](const QString& healthFile = QString()) {
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;
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);
return started; return started;
}; };
const auto bootstrapPlanFile = [&]() { const auto bootstrapPlanFile = [&]() {
return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt"); return QDir(updateDir).filePath("bootstrap_plan_" + transaction.transactionId() + ".txt");
}; };
const auto launchBootstrap = [&](const QString& mode) { const auto launchBootstrap = [&](const QString& mode) {
const QStringList args{ const QStringList args{
bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath, bootstrapPlanFile(), targetDir, transaction.stagingDir(), transaction.backupDir(), updaterPath,
QString::number(QCoreApplication::applicationPid()), appId, channel, QString::number(QCoreApplication::applicationPid()), appId, channel,
targetVersion, QString::number(targetVersionId), mode targetVersion, QString::number(targetVersionId), mode
}; };
return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args); return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args);
}; };
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("正在将回滚工作移交给 Bootstrap...");
QApplication::processEvents(); QApplication::processEvents();
if (launchBootstrap("rollback")) { if (launchBootstrap("rollback")) {
progress.close(); progress.close();
return 0; return 0;
} }
reportUpdateResult(false); reportUpdateResult(false);
progress.close(); progress.close();
QMessageBox::critical(nullptr, title, QMessageBox::critical(nullptr, title,
reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。"); reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。");
return -1; return -1;
}; };
if (resumingFromBootstrap) { if (resumingFromBootstrap) {
QString resumeError; QString resumeError;
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)); QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError));
return -1; return -1;
} }
fromVersion = transaction.fromVersion(); fromVersion = transaction.fromVersion();
if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode"))) if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode")))
offlinePackagePath = "__bootstrap_resumed_offline__"; offlinePackagePath = "__bootstrap_resumed_offline__";
if (bootstrapResult == "rolledback") { if (bootstrapResult == "rolledback") {
const bool stateOk = config.setValue("App", "current_version", fromVersion); const bool stateOk = config.setValue("App", "current_version", fromVersion);
transaction.markRolledBack(); transaction.markRolledBack();
reportUpdateResult(false); reportUpdateResult(false);
if (stateOk) launchMainApp(); if (stateOk) launchMainApp();
progress.close(); progress.close();
QMessageBox::warning(nullptr, "更新已回滚", QMessageBox::warning(nullptr, "更新已回滚",
stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。" stateOk ? "新版本安装或启动失败,已自动恢复并启动旧版本。"
: "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。"); : "旧文件已经恢复,但旧版本号写回失败,请检查配置目录权限。");
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 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。"); "Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。");
return -1; return -1;
} }
} else if (!transaction.initialize()) { } else if (!transaction.initialize()) {
return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed"); return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "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("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed");
} }
setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包..."); setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包...");
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("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。");
} else if (offlinePackagePath.isEmpty()) { } else if (offlinePackagePath.isEmpty()) {
logic.getManifest(appId, channel, targetVersion, targetVersionId); logic.getManifest(appId, channel, targetVersion, targetVersionId);
} }
if (!logic.verifyManifestSignature()) { if (!logic.verifyManifestSignature()) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。"); return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。");
return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid"); return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid");
} }
QStringList obsoletePaths; QStringList obsoletePaths;
if (!resumingFromBootstrap && fromVersion != targetVersion) { if (!resumingFromBootstrap && fromVersion != targetVersion) {
UpdaterLogic oldManifestLogic; UpdaterLogic oldManifestLogic;
if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion) if (oldManifestLogic.loadManifestCache(manifestCacheDir, fromVersion)
&& oldManifestLogic.getManifest().value("version").toString() == fromVersion && oldManifestLogic.getManifest().value("version").toString() == fromVersion
&& oldManifestLogic.verifyManifestSignature()) { && oldManifestLogic.verifyManifestSignature()) {
obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest()); obsoletePaths = logic.obsoleteFilesComparedTo(oldManifestLogic.getManifest());
qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths; qDebug() << "Obsolete files from signed previous manifest:" << obsoletePaths;
} else { } else {
qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety"; qDebug() << "No valid signed previous manifest cache; obsolete deletion skipped for safety";
} }
} }
if (!logic.saveManifestCache(manifestCacheDir)) { if (!logic.saveManifestCache(manifestCacheDir)) {
if (resumingFromBootstrap) if (resumingFromBootstrap)
return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。"); return delegateRollback("清单缓存失败", "无法保存新版本 Manifest 缓存。");
return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed"); return fail("清单缓存失败", "无法保存新版本 Manifest 缓存,更新已停止。", "manifest_cache_failed");
} }
if (!resumingFromBootstrap) { if (!resumingFromBootstrap) {
setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件..."); setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件...");
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("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "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("磁盘空间不足",
QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n" QString("更新至少需要 %1 可用空间,安装盘当前仅剩 %2。\n"
"所需空间已包含下载文件、旧版本备份和安全余量。") "所需空间已包含下载文件、旧版本备份和安全余量。")
.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, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").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("离线包提取失败", 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("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed");
} }
logic.reportDownloadResult(appId, channel, targetVersion, true); logic.reportDownloadResult(appId, channel, targetVersion, true);
} }
setProgress(58, "正在校验完整版本文件..."); setProgress(58, "正在校验完整版本文件...");
if (!logic.validateLocalFiles(stagingDir, targetDir)) if (!logic.validateLocalFiles(stagingDir, targetDir))
return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed"); return fail("文件校验失败", "暂存文件与版本清单不一致,更新已停止。", "staging_verify_failed");
QStringList changedPaths; QStringList changedPaths;
QDir stagingRoot(stagingDir); QDir stagingRoot(stagingDir);
QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories); QDirIterator stagingFiles(stagingDir, QDir::Files, QDirIterator::Subdirectories);
while (stagingFiles.hasNext()) while (stagingFiles.hasNext())
changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next()))); changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next())));
const QString runtimePrefix = config.runtimeRelativePath(); const QString runtimePrefix = config.runtimeRelativePath();
const QString bootstrapManifestPath = QDir::fromNativeSeparators( const QString bootstrapManifestPath = QDir::fromNativeSeparators(
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("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapManifestPath), "bootstrap_self_update_blocked");
} }
if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths)) if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths))
return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed"); return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed");
setProgress(66, "正在关闭主程序..."); setProgress(66, "正在关闭主程序...");
if (!FileHelper::killProcess(mainExecutable)) if (!FileHelper::killProcess(mainExecutable))
return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed"); return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed");
setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...") setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...")
.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("备份失败", "无法备份当前版本文件,尚未安装新版本。请检查磁盘空间和目录权限。", "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("接管准备失败", "无法创建 Bootstrap 文件计划。", "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();
}; };
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("接管准备失败", "无法写入 Bootstrap 复制计划。", "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("接管准备失败", "无法写入 Bootstrap 删除计划。", "bootstrap_plan_failed");
} }
} }
if (!plan.commit() || !transaction.markAwaitingBootstrap()) if (!plan.commit() || !transaction.markAwaitingBootstrap())
return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed"); return fail("接管准备失败", "无法提交 Bootstrap 文件计划或事务状态。", "bootstrap_plan_failed");
setProgress(78, "正在将安装工作移交给 Bootstrap..."); setProgress(78, "正在将安装工作移交给 Bootstrap...");
if (!launchBootstrap("install")) if (!launchBootstrap("install"))
return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed"); return fail("Bootstrap 启动失败", QString("无法启动独立更新接管程序:%1").arg(bootstrapPath), "bootstrap_start_failed");
progress.close(); progress.close();
return 0; return 0;
} }
setProgress(82, "正在校验 Bootstrap 安装结果..."); setProgress(82, "正在校验 Bootstrap 安装结果...");
if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir)) if (!transaction.markPostVerify() || !logic.validateLocalFiles(targetDir))
return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。"); return delegateRollback("安装校验失败", "新版本文件安装后校验未通过。");
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("废弃文件清理失败", QString("废弃文件仍然存在:%1").arg(path));
} }
setProgress(89, "正在保存新版本状态..."); setProgress(89, "正在保存新版本状态...");
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("状态保存失败", "无法保存当前版本号。");
const QString healthFile = transaction.healthFile(); const QString healthFile = transaction.healthFile();
QFile::remove(healthFile); QFile::remove(healthFile);
setProgress(94, "正在启动新版本并等待健康确认..."); setProgress(94, "正在启动新版本并等待健康确认...");
if (!launchMainApp(healthFile)) if (!launchMainApp(healthFile))
return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable)); return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable));
QElapsedTimer healthTimer; QElapsedTimer healthTimer;
healthTimer.start(); healthTimer.start();
while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) { while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) {
QApplication::processEvents(); QApplication::processEvents();
QThread::msleep(100); QThread::msleep(100);
} }
if (!QFile::exists(healthFile)) if (!QFile::exists(healthFile))
return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs)); return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs));
setProgress(99, "正在提交更新事务..."); setProgress(99, "正在提交更新事务...");
if (!transaction.commit()) if (!transaction.commit())
return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。"); return delegateRollback("事务提交失败", "新版本已经启动,但无法提交更新事务。");
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, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion));
return 0; return 0;
} }
+22 -22
View File
@@ -1,22 +1,22 @@
{ {
"app_id": "simcae", "app_id": "simcae",
"app_name": "SimCAE", "app_name": "SimCAE",
"channel": "stable", "channel": "stable",
"current_version": "1.0.0", "current_version": "1.0.0",
"client_protocol": "3", "client_protocol": "3",
"launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes",
"license_key": "", "license_key": "",
"api_base_url": "http://YOUR_SERVER_IP:8000", "api_base_url": "http://YOUR_SERVER_IP:8000",
"client_token": "SimCAEClientToken2026", "client_token": "SimCAEClientToken2026",
"request_timeout_ms": "5000", "request_timeout_ms": "5000",
"temp_folder": "update_temp", "temp_folder": "update_temp",
"device_id": "", "device_id": "",
"install_root": "..", "install_root": "..",
"main_executable": "SimCAE.exe", "main_executable": "SimCAE.exe",
"launcher_executable": "Launcher.exe", "launcher_executable": "Launcher.exe",
"updater_executable": "Updater.exe", "updater_executable": "Updater.exe",
"bootstrap_executable": "Bootstrap.exe", "bootstrap_executable": "Bootstrap.exe",
"health_check_timeout_ms": "15000", "health_check_timeout_ms": "15000",
"platform": "windows", "platform": "windows",
"arch": "x64" "arch": "x64"
} }
+9 -9
View File
@@ -1,9 +1,9 @@
-----BEGIN PUBLIC KEY----- -----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMROESbT36XU4d3YjuwU
WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l WAC2h5p3btFu/IAeF3bVtHMovIA4ZXXKYsiq5FycDnDzyD86ou3B7PP7uHhVhn2l
Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ Uru7QElYRfEfQAFSU5dErc+SZo+oT170cgq1ePPD/YleKPRqFAL221Tbh5pusHcZ
Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv Ocujit2qrfg5f4rIEzWu7kBKVSJ6WChkjetEL6OZ43ClkDyUGebUuaQ9dv39YVlv
Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs Fp2pfARi7/7djMMncLVaFU2AAIuSy3jgQg65DOFRVPSr1P/rRfuqU55ZmqAmmZIs
F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ F/KVpne6zLFBXrxf5rNTBch+nxX+hcE7M+K0PJA5Ie669qRQFRwxoJlGpSOvMefQ
TwIDAQAB TwIDAQAB
-----END PUBLIC KEY----- -----END PUBLIC KEY-----
+74 -74
View File
@@ -1,74 +1,74 @@
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)]
[string]$SdkRoot, [string]$SdkRoot,
[string]$ReleaseDir = (Get-Location).Path, [string]$ReleaseDir = (Get-Location).Path,
[switch]$OverwriteConfig, [switch]$OverwriteConfig,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$sdk = (Resolve-Path $SdkRoot).Path $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" $configDir = Join-Path $sdk "config"
$appConfig = Join-Path $configDir "app_config.json" $appConfig = Join-Path $configDir "app_config.json"
$publicKey = Join-Path $configDir "manifest_public_key.pem" $publicKey = Join-Path $configDir "manifest_public_key.pem"
foreach ($path in @($binDir, $appConfig, $publicKey)) { foreach ($path in @($binDir, $appConfig, $publicKey)) {
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
throw "SDK file is missing: $path" throw "SDK file is missing: $path"
} }
} }
function Test-IsQtRuntimeItem { function Test-IsQtRuntimeItem {
param([System.IO.FileSystemInfo]$Item) param([System.IO.FileSystemInfo]$Item)
if ($IncludeQtRuntime) { if ($IncludeQtRuntime) {
return $false return $false
} }
if ($Item.PSIsContainer) { if ($Item.PSIsContainer) {
return $Item.Name -in @( return $Item.Name -in @(
"bearer", "bearer",
"iconengines", "iconengines",
"imageformats", "imageformats",
"platforms", "platforms",
"styles", "styles",
"translations" "translations"
) )
} }
return ( return (
$Item.Name -match '^Qt5.*\.dll$' -or $Item.Name -match '^Qt5.*\.dll$' -or
$Item.Name -in @( $Item.Name -in @(
"libEGL.dll", "libEGL.dll",
"libGLESv2.dll", "libGLESv2.dll",
"opengl32sw.dll", "opengl32sw.dll",
"d3dcompiler_47.dll" "d3dcompiler_47.dll"
) )
) )
} }
Get-ChildItem $binDir -Force | Where-Object { Get-ChildItem $binDir -Force | Where-Object {
-not (Test-IsQtRuntimeItem $_) -not (Test-IsQtRuntimeItem $_)
} | Copy-Item -Destination $release -Recurse -Force } | Copy-Item -Destination $release -Recurse -Force
$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" $targetAppConfig = Join-Path $targetConfigDir "app_config.json"
if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) { if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) {
Copy-Item $appConfig $targetAppConfig -Force Copy-Item $appConfig $targetAppConfig -Force
} else { } else {
Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it." Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it."
} }
Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force 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: edit config/app_config.json, then start Launcher.exe."
+138 -138
View File
@@ -1,138 +1,138 @@
param( param(
[string]$SourceDir = "$PSScriptRoot/out/bin", [string]$SourceDir = "$PSScriptRoot/out/bin",
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$ConfigFile, [string]$ConfigFile,
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClient", [string]$OutputDir = "$PSScriptRoot/dist/UpdateClient",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip" [string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$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
function Normalize-RelativePath([string]$PathValue) { function Normalize-RelativePath([string]$PathValue) {
return ($PathValue -replace '\\', '/').Trim('/') return ($PathValue -replace '\\', '/').Trim('/')
} }
function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) { function Get-RelativePathUnderSource([string]$FullPath, [string]$Fallback) {
$full = (Resolve-Path $FullPath).Path $full = (Resolve-Path $FullPath).Path
if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) { if ($full.StartsWith($source, [System.StringComparison]::OrdinalIgnoreCase)) {
return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/')) return Normalize-RelativePath ($full.Substring($source.Length).TrimStart('\', '/'))
} }
return Normalize-RelativePath $Fallback return Normalize-RelativePath $Fallback
} }
$configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json" $configRelativePath = Get-RelativePathUnderSource $config "config/app_config.json"
$configRelativeParent = Split-Path $configRelativePath -Parent $configRelativeParent = Split-Path $configRelativePath -Parent
$runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent) $runtimeDirRelative = Normalize-RelativePath (Split-Path $configRelativeParent -Parent)
if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" } if ($runtimeDirRelative -eq ".") { $runtimeDirRelative = "" }
function Join-RelativePath([string]$Base, [string]$Child) { function Join-RelativePath([string]$Base, [string]$Child) {
$baseNorm = Normalize-RelativePath $Base $baseNorm = Normalize-RelativePath $Base
$childNorm = Normalize-RelativePath $Child $childNorm = Normalize-RelativePath $Child
if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm } if ([string]::IsNullOrWhiteSpace($baseNorm)) { return $childNorm }
if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm } if ([string]::IsNullOrWhiteSpace($childNorm)) { return $baseNorm }
return "$baseNorm/$childNorm" return "$baseNorm/$childNorm"
} }
$requiredFields = @( $requiredFields = @(
"app_id", "channel", "api_base_url", "current_version", "app_id", "channel", "api_base_url", "current_version",
"client_token", "launch_token", "license_key", "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) {
if (-not $settings.$field) { if (-not $settings.$field) {
throw "Config file is missing required field: $field" throw "Config file is missing required field: $field"
} }
} }
$mainExecutable = Normalize-RelativePath $settings.main_executable $mainExecutable = Normalize-RelativePath $settings.main_executable
$launcherExecutable = Normalize-RelativePath $settings.launcher_executable $launcherExecutable = Normalize-RelativePath $settings.launcher_executable
$updaterExecutable = Normalize-RelativePath $settings.updater_executable $updaterExecutable = Normalize-RelativePath $settings.updater_executable
$bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable $bootstrapExecutable = Normalize-RelativePath $settings.bootstrap_executable
$requiredFiles = @( $requiredFiles = @(
(Join-RelativePath $runtimeDirRelative $mainExecutable), (Join-RelativePath $runtimeDirRelative $mainExecutable),
(Join-RelativePath $runtimeDirRelative $launcherExecutable), (Join-RelativePath $runtimeDirRelative $launcherExecutable),
(Join-RelativePath $runtimeDirRelative $updaterExecutable), (Join-RelativePath $runtimeDirRelative $updaterExecutable),
(Join-RelativePath $runtimeDirRelative $bootstrapExecutable) (Join-RelativePath $runtimeDirRelative $bootstrapExecutable)
) )
foreach ($name in $requiredFiles) { foreach ($name in $requiredFiles) {
$nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar $nativeName = $name -replace '/', [IO.Path]::DirectorySeparatorChar
if (-not (Test-Path (Join-Path $source $nativeName))) { if (-not (Test-Path (Join-Path $source $nativeName))) {
throw "Source directory is missing required file: $name" throw "Source directory is missing required file: $name"
} }
} }
$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')
} }
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 out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)"
} }
$expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable $expectedMainPath = Join-RelativePath $runtimeDirRelative $mainExecutable
$mainLeafName = Split-Path $mainExecutable -Leaf $mainLeafName = Split-Path $mainExecutable -Leaf
$duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object { $duplicateMain = Get-ChildItem $source -Recurse -File -Filter $mainLeafName | Where-Object {
$relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/')) $relative = Normalize-RelativePath ($_.FullName.Substring($source.Length).TrimStart('\', '/'))
$relative -ne $expectedMainPath $relative -ne $expectedMainPath
} | Select-Object -First 1 } | Select-Object -First 1
if ($duplicateMain) { if ($duplicateMain) {
throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)" throw "Source directory contains a duplicate main executable outside $expectedMainPath. Use a clean Release root directory: $($duplicateMain.FullName)"
} }
$manifestName = "manifest_$($settings.current_version).json" $manifestName = "manifest_$($settings.current_version).json"
$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) $sourceManifest = Join-Path $source ($sourceManifestRelative -replace '/', [IO.Path]::DirectorySeparatorChar)
if (-not (Test-Path $sourceManifest)) { if (-not (Test-Path $sourceManifest)) {
$legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName" $legacySourceManifest = Join-Path $source "update/manifest_cache/$manifestName"
if (Test-Path $legacySourceManifest) { if (Test-Path $legacySourceManifest) {
$sourceManifest = $legacySourceManifest $sourceManifest = $legacySourceManifest
} }
} }
if (-not (Test-Path $sourceManifest)) { if (-not (Test-Path $sourceManifest)) {
throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging."
} }
if (Test-Path $OutputDir) { if (Test-Path $OutputDir) {
Remove-Item $OutputDir -Recurse -Force Remove-Item $OutputDir -Recurse -Force
} }
New-Item $OutputDir -ItemType Directory -Force | Out-Null New-Item $OutputDir -ItemType Directory -Force | Out-Null
Get-ChildItem $source -Force | Where-Object { Get-ChildItem $source -Force | Where-Object {
$_.Name -notin @("update", "update_temp") $_.Name -notin @("update", "update_temp")
} | Copy-Item -Destination $OutputDir -Recurse -Force } | Copy-Item -Destination $OutputDir -Recurse -Force
$outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar) $outputRuntimeUpdateDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update") -replace '/', [IO.Path]::DirectorySeparatorChar)
if (Test-Path $outputRuntimeUpdateDir) { if (Test-Path $outputRuntimeUpdateDir) {
Remove-Item $outputRuntimeUpdateDir -Recurse -Force Remove-Item $outputRuntimeUpdateDir -Recurse -Force
} }
$outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar) $outputRuntimeTempDir = Join-Path $OutputDir ((Join-RelativePath $runtimeDirRelative "update_temp") -replace '/', [IO.Path]::DirectorySeparatorChar)
if (Test-Path $outputRuntimeTempDir) { if (Test-Path $outputRuntimeTempDir) {
Remove-Item $outputRuntimeTempDir -Recurse -Force Remove-Item $outputRuntimeTempDir -Recurse -Force
} }
$outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar) $outputConfigPath = Join-Path $OutputDir ($configRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar)
$outputConfigDir = Split-Path $outputConfigPath -Parent $outputConfigDir = Split-Path $outputConfigPath -Parent
New-Item $outputConfigDir -ItemType Directory -Force | Out-Null New-Item $outputConfigDir -ItemType Directory -Force | Out-Null
Copy-Item $config $outputConfigPath -Force Copy-Item $config $outputConfigPath -Force
@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { @("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object {
$runtimeFile = Join-Path $outputConfigDir $_ $runtimeFile = Join-Path $outputConfigDir $_
if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -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)
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
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
Write-Host "Package directory: $OutputDir" Write-Host "Package directory: $OutputDir"
Write-Host "ZIP file: $ZipFile" Write-Host "ZIP file: $ZipFile"
+116 -116
View File
@@ -1,116 +1,116 @@
param( param(
[string]$SourceDir = "$PSScriptRoot/out/bin", [string]$SourceDir = "$PSScriptRoot/out/bin",
[string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK", [string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK",
[string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", [string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip",
[string]$SdkVersion = "0.1.0", [string]$SdkVersion = "0.1.0",
[string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", [string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json",
[switch]$IncludeDemoMainApp, [switch]$IncludeDemoMainApp,
[switch]$IncludeQtRuntime [switch]$IncludeQtRuntime
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$source = (Resolve-Path $SourceDir).Path $source = (Resolve-Path $SourceDir).Path
$exampleConfigPath = (Resolve-Path $ExampleConfig).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) {
$path = Join-Path $source $name $path = Join-Path $source $name
if (-not (Test-Path $path)) { if (-not (Test-Path $path)) {
throw "SDK source directory is missing required file: $path" throw "SDK source directory is missing required file: $path"
} }
} }
$publicKeyCandidates = @( $publicKeyCandidates = @(
(Join-Path $source "config/manifest_public_key.pem"), (Join-Path $source "config/manifest_public_key.pem"),
(Join-Path $source "manifest_public_key.pem"), (Join-Path $source "manifest_public_key.pem"),
(Join-Path $PSScriptRoot "config/manifest_public_key.pem") (Join-Path $PSScriptRoot "config/manifest_public_key.pem")
) )
$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 $publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $publicKey) { if (-not $publicKey) {
throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." 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')
} }
if ($debugArtifacts) { if ($debugArtifacts) {
throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)" throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)"
} }
if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force } if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force }
New-Item $OutputDir -ItemType Directory -Force | Out-Null 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 New-Item $binDir,$configDir,$scriptsDir -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" }
if (-not $IncludeQtRuntime) { if (-not $IncludeQtRuntime) {
$excludedTopLevel += @( $excludedTopLevel += @(
"bearer", "bearer",
"iconengines", "iconengines",
"imageformats", "imageformats",
"platforms", "platforms",
"styles", "styles",
"translations" "translations"
) )
} }
function Test-IsQtRuntimeFile { function Test-IsQtRuntimeFile {
param([System.IO.FileSystemInfo]$Item) param([System.IO.FileSystemInfo]$Item)
if ($IncludeQtRuntime -or $Item.PSIsContainer) { if ($IncludeQtRuntime -or $Item.PSIsContainer) {
return $false return $false
} }
return ( return (
$Item.Name -match '^Qt5.*\.dll$' -or $Item.Name -match '^Qt5.*\.dll$' -or
$Item.Name -in @( $Item.Name -in @(
"libEGL.dll", "libEGL.dll",
"libGLESv2.dll", "libGLESv2.dll",
"opengl32sw.dll", "opengl32sw.dll",
"d3dcompiler_47.dll" "d3dcompiler_47.dll"
) )
) )
} }
Get-ChildItem $source -Force | Where-Object { 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 Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force
Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force
$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { $wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object {
$_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*"
} | Sort-Object Name | Select-Object -First 1 } | Sort-Object Name | Select-Object -First 1
if (-not $wordGuideSource) { if (-not $wordGuideSource) {
throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory."
} }
Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force
Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force Copy-Item (Join-Path $PSScriptRoot "package-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
Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force
@{ @{
sdk_version = $SdkVersion sdk_version = $SdkVersion
generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
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
} | 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
New-Item $zipParent -ItemType Directory -Force | Out-Null New-Item $zipParent -ItemType Directory -Force | Out-Null
if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force }
Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal
Write-Host "SDK directory: $OutputDir" Write-Host "SDK directory: $OutputDir"
Write-Host "SDK zip: $ZipFile" Write-Host "SDK zip: $ZipFile"
Write-Host "SDK version: $SdkVersion" Write-Host "SDK version: $SdkVersion"
+2942 -2942
View File
File diff suppressed because it is too large Load Diff