From d669adb7074385cb766a00438a75e9baaca6818b Mon Sep 17 00:00:00 2001 From: vkamn Date: Thu, 11 Dec 2025 18:54:24 +0800 Subject: [PATCH] feat: msi installer and cli command (#2020) * feat: Add msi quite installer * chore: update code for new wix * feat: add cpack wix installer * feat: add gihub workflow for msi * chore: fix deploy script * chore: add wix logs * chore: fix msi build * chore: fix msi build * chore: add wix exts log * chore: add cpackwixpatch for registering the service * chore: fix build script * chore: fix wix fragment * feat: add closing app with reinstalling * chore: update version for test * chore: fix build script * feat: added cli commands --connect and --import (#1967) * fix: delete unused file and disable rollback after unsuccessful service start in msi installer * fix: Add deps to msi * fix: msi deps * feat: added os signal handler * fix: incorrect import at the empty client start (#2024) * chore: add force quit for os signal handler * feat: os signal handler improvements * fix: fixed --connection command --------- Co-authored-by: Mykola Baibuz Co-authored-by: aiamnezia Co-authored-by: Mitternacht822 --- .github/workflows/deploy.yml | 23 +++++ CMakeLists.txt | 33 +++++++ client/amnezia_application.cpp | 44 ++++++++- client/amnezia_application.h | 6 ++ client/core/controllers/coreController.cpp | 19 ++++ client/core/controllers/coreController.h | 3 + client/core/osSignalHandler.cpp | 89 +++++++++++++------ deploy/build_windows.bat | 57 +++++++++++- deploy/installer/wix/close_client_patch.xml | 13 +++ .../installer/wix/service_install_patch.xml | 24 +++++ 10 files changed, 280 insertions(+), 31 deletions(-) create mode 100644 deploy/installer/wix/close_client_patch.xml create mode 100644 deploy/installer/wix/service_install_patch.xml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 42bd50229..bfc49ac70 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -139,12 +139,28 @@ jobs: with: arch: 'x64' + - name: 'Setup .NET SDK' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + - name: 'Install WiX Toolset' + shell: powershell + run: | + dotnet tool install --global wix --version 4.0.6 + wix extension add -g WixToolset.UI.wixext/4.0.6 + wix extension add -g WixToolset.Util.wixext/4.0.6 + wix extension list -g + $wixBinDir = Join-Path $env:USERPROFILE ".dotnet\tools" + echo "WIX_BIN_DIR=$wixBinDir" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: 'Build project' shell: cmd run: | set BUILD_ARCH=${{ env.BUILD_ARCH }} set QT_BIN_DIR="${{ runner.temp }}\\Qt\\${{ env.QT_VERSION }}\\msvc2019_64\\bin" set QIF_BIN_DIR="${{ runner.temp }}\\Qt\\Tools\\QtInstallerFramework\\${{ env.QIF_VERSION }}\\bin" + set WIX_BIN_DIR=%USERPROFILE%\.dotnet\tools call deploy\\build_windows.bat - name: 'Rename Windows installer' @@ -159,6 +175,13 @@ jobs: path: AmneziaVPN_${{ env.VERSION }}_x64.exe retention-days: 7 + - name: 'Upload MSI installer artifact' + uses: actions/upload-artifact@v4 + with: + name: AmneziaVPN_Windows_MSI_installer + path: AmneziaVPN_x${{ env.BUILD_ARCH }}.msi + retention-days: 7 + - name: 'Upload unpacked artifact' uses: actions/upload-artifact@v4 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index 91fc2ea04..9cf071105 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -49,3 +49,36 @@ if(NOT IOS AND NOT ANDROID AND NOT MACOS_NE) include(${CMAKE_SOURCE_DIR}/deploy/installer/config.cmake) endif() + +set(AMNEZIA_STAGE_DIR "${CMAKE_BINARY_DIR}/stage") + +if(WIN32 AND NOT IOS AND NOT ANDROID AND NOT MACOS_NE) + file(TO_CMAKE_PATH "${AMNEZIA_STAGE_DIR}" AMNEZIA_STAGE_DIR_CMAKE) + + set(CPACK_GENERATOR "WIX") + set(CPACK_WIX_VERSION 4) + set(CPACK_PACKAGE_NAME "AmneziaVPN") + set(CPACK_PACKAGE_VENDOR "AmneziaVPN") + set(CPACK_PACKAGE_VERSION ${AMNEZIAVPN_VERSION}) + set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "AmneziaVPN client") + set(CPACK_PACKAGE_INSTALL_DIRECTORY "AmneziaVPN") + set(CPACK_PACKAGE_DIRECTORY "${CMAKE_BINARY_DIR}") + set(CPACK_PACKAGE_EXECUTABLES "AmneziaVPN" "AmneziaVPN") + set(CPACK_WIX_UPGRADE_GUID "{2D55AC62-96D6-4692-8C05-0D85BBF95485}") + set(CPACK_WIX_PRODUCT_ICON "${CMAKE_SOURCE_DIR}/client/images/app.ico") + + # WiX patches + set(_AMNEZIA_WIX_PATCH_SERVICE "${CMAKE_SOURCE_DIR}/deploy/installer/wix/service_install_patch.xml") + set(_AMNEZIA_WIX_PATCH_CLOSE_APP "${CMAKE_SOURCE_DIR}/deploy/installer/wix/close_client_patch.xml") + file(TO_CMAKE_PATH "${_AMNEZIA_WIX_PATCH_SERVICE}" _AMNEZIA_WIX_PATCH_SERVICE_CMAKE) + file(TO_CMAKE_PATH "${_AMNEZIA_WIX_PATCH_CLOSE_APP}" _AMNEZIA_WIX_PATCH_CLOSE_APP_CMAKE) + set(CPACK_WIX_PATCH_FILE "${_AMNEZIA_WIX_PATCH_SERVICE_CMAKE};${_AMNEZIA_WIX_PATCH_CLOSE_APP_CMAKE}") + + # WiX v4 Util extension for CloseApplication + namespace for util + set(CPACK_WIX_EXTENSIONS "${CPACK_WIX_EXTENSIONS};WixToolset.Util.wixext") + set(CPACK_WIX_CUSTOM_XMLNS "util=http://wixtoolset.org/schemas/v4/wxs/util") + + set(CPACK_INSTALLED_DIRECTORIES "${AMNEZIA_STAGE_DIR_CMAKE};/") + + include(CPack) +endif() diff --git a/client/amnezia_application.cpp b/client/amnezia_application.cpp index 823e80b35..3db45d5ec 100644 --- a/client/amnezia_application.cpp +++ b/client/amnezia_application.cpp @@ -27,9 +27,13 @@ #include // for QQuickWindow #include // for qobject_cast +bool AmneziaApplication::m_forceQuit = false; + AmneziaApplication::AmneziaApplication(int &argc, char *argv[]) : AMNEZIA_BASE_CLASS(argc, argv), m_optAutostart({QStringLiteral("a"), QStringLiteral("autostart")}, QStringLiteral("System autostart")), - m_optCleanup ({QStringLiteral("c"), QStringLiteral("cleanup")}, QStringLiteral("Cleanup logs")) + m_optCleanup ({QStringLiteral("c"), QStringLiteral("cleanup")}, QStringLiteral("Cleanup logs")), + m_optConnect ({QStringLiteral("connect")}, QStringLiteral("Connect to server by index on startup"), QStringLiteral("index")), + m_optImport ({QStringLiteral("import")}, QStringLiteral("Import configuration from data string"), QStringLiteral("data")) { setQuitOnLastWindowClosed(false); @@ -126,6 +130,16 @@ void AmneziaApplication::init() m_coreController.reset(new CoreController(m_vpnConnection, m_settings, m_engine)); m_engine->addImportPath("qrc:/ui/qml/Modules/"); + + if (m_parser.isSet(m_optImport)) { + const QString data = m_parser.value(m_optImport); + if (!data.isEmpty()) { + if (m_coreController) { + m_coreController->importConfigFromData(data); + } + } + } + m_engine->load(url); m_coreController->setQmlRoot(); @@ -165,6 +179,18 @@ void AmneziaApplication::init() } }); #endif + + if (m_parser.isSet(m_optConnect)) { + bool ok = false; + int idx = m_parser.value(m_optConnect).toInt(&ok); + if (ok) { + QTimer::singleShot(0, this, [this, idx]() { + if (m_coreController) { + m_coreController->openConnectionByIndex(idx); + } + }); + } + } } void AmneziaApplication::registerTypes() @@ -211,6 +237,8 @@ bool AmneziaApplication::parseCommands() m_parser.addOption(m_optAutostart); m_parser.addOption(m_optCleanup); + m_parser.addOption(m_optConnect); + m_parser.addOption(m_optImport); m_parser.process(*this); @@ -247,8 +275,12 @@ bool AmneziaApplication::eventFilter(QObject *watched, QEvent *event) #if defined(Q_OS_ANDROID) || defined(Q_OS_IOS) quit(); #else - if (m_coreController && m_coreController->pageController()) { - m_coreController->pageController()->hideMainWindow(); + if (m_forceQuit) { + quit(); + } else { + if (m_coreController && m_coreController->pageController()) { + m_coreController->pageController()->hideMainWindow(); + } } #endif return true; // eat the close @@ -257,6 +289,12 @@ bool AmneziaApplication::eventFilter(QObject *watched, QEvent *event) return QObject::eventFilter(watched, event); } +void AmneziaApplication::forceQuit() +{ + m_forceQuit = true; + quit(); +} + QQmlApplicationEngine *AmneziaApplication::qmlEngine() const { return m_engine; diff --git a/client/amnezia_application.h b/client/amnezia_application.h index 9926254a3..c2f790354 100644 --- a/client/amnezia_application.h +++ b/client/amnezia_application.h @@ -45,7 +45,11 @@ public: QNetworkAccessManager *networkManager(); QClipboard *getClipboard(); +public slots: + void forceQuit(); + private: + static bool m_forceQuit; QQmlApplicationEngine *m_engine {}; std::shared_ptr m_settings; @@ -58,6 +62,8 @@ private: QCommandLineOption m_optAutostart; QCommandLineOption m_optCleanup; + QCommandLineOption m_optConnect; + QCommandLineOption m_optImport; QSharedPointer m_vpnConnection; QThread m_vpnConnectionThread; diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 510066964..beec3131c 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -411,3 +411,22 @@ QSharedPointer CoreController::pageController() const { return m_pageController; } + +void CoreController::openConnectionByIndex(int serverIndex) +{ + if (m_serversModel) { + m_serversModel->setProcessedServerIndex(serverIndex); + m_serversModel->setDefaultServerIndex(serverIndex); + } + m_connectionController->toggleConnection(); +} + +void CoreController::importConfigFromData(const QString &data) +{ + if (!m_importController) + return; + + if (m_importController->extractConfigFromData(data)) { + m_importController->importConfig(); + } +} diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 404a17828..f15f6d31a 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -65,6 +65,9 @@ public: QSharedPointer pageController() const; void setQmlRoot(); + void openConnectionByIndex(int serverIndex); + void importConfigFromData(const QString &data); + signals: void translationsUpdated(); void websiteUrlChanged(const QString &newUrl); diff --git a/client/core/osSignalHandler.cpp b/client/core/osSignalHandler.cpp index d4b9edea4..e304f2aed 100644 --- a/client/core/osSignalHandler.cpp +++ b/client/core/osSignalHandler.cpp @@ -1,8 +1,11 @@ #include "osSignalHandler.h" #include +#include #include +#include "../amnezia_application.h" + #if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) #include #include @@ -15,7 +18,8 @@ #endif #ifdef Q_OS_WIN - #include + #include + #include #endif @@ -25,21 +29,30 @@ namespace static bool initialized = false; #ifdef Q_OS_WIN - static BOOL WINAPI consoleHandler(DWORD signal) + class WindowsCloseFilter : public QAbstractNativeEventFilter { - switch (signal) { - case CTRL_CLOSE_EVENT: - case CTRL_C_EVENT: - case CTRL_BREAK_EVENT: - case CTRL_LOGOFF_EVENT: - case CTRL_SHUTDOWN_EVENT: - if (QCoreApplication::instance()) { - QMetaObject::invokeMethod(QCoreApplication::instance(), "quit", Qt::QueuedConnection); + public: + bool nativeEventFilter(const QByteArray &eventType, void *message, qintptr *result) override + { + MSG *msg = static_cast(message); + + switch (msg->message) { + case WM_CLOSE: { + const HWND active = GetActiveWindow(); + const HWND self = msg->hwnd; + if (active != self) { + AmneziaApplication *app = qobject_cast(QCoreApplication::instance()); + if (app) { + QMetaObject::invokeMethod(app, "forceQuit", Qt::QueuedConnection); + } + } } - return TRUE; - default: return FALSE; - } - } + } + return false; + }; + }; + + static WindowsCloseFilter *windowsFilter = nullptr; #endif #if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) @@ -70,14 +83,16 @@ namespace } }); } -#elif defined(Q_OS_MACX) +#elif defined(Q_OS_MACOS) static int signalPipe[2] = { -1, -1 }; static QSocketNotifier *socketNotifier = nullptr; static void macSignalHandler(int) { - const char ch = 1; - ::write(signalPipe[1], &ch, sizeof(ch)); + if (signalPipe[1] >= 0) { + const char ch = 1; + ::write(signalPipe[1], &ch, sizeof(ch)); + } } static void setupUnixSignalHandler() @@ -88,14 +103,6 @@ namespace ::fcntl(signalPipe[0], F_SETFL, O_NONBLOCK); ::fcntl(signalPipe[1], F_SETFL, O_NONBLOCK); - struct sigaction sa {}; - sa.sa_handler = macSignalHandler; - sigemptyset(&sa.sa_mask); - sa.sa_flags = 0; - - sigaction(SIGINT, &sa, nullptr); - sigaction(SIGTERM, &sa, nullptr); - socketNotifier = new QSocketNotifier(signalPipe[0], QSocketNotifier::Read, QCoreApplication::instance()); QObject::connect(socketNotifier, &QSocketNotifier::activated, QCoreApplication::instance(), [](int) { @@ -103,6 +110,14 @@ namespace ::read(signalPipe[0], buf, sizeof(buf)); QCoreApplication::quit(); }); + + struct sigaction sa {}; + sa.sa_handler = macSignalHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); } #endif @@ -111,6 +126,8 @@ namespace #if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) if (socketNotifier) { socketNotifier->setEnabled(false); + socketNotifier->deleteLater(); + socketNotifier = nullptr; } if (signalFd >= 0) { @@ -119,8 +136,17 @@ namespace } #elif defined(Q_OS_MACOS) + struct sigaction sa {}; + sa.sa_handler = SIG_DFL; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + if (socketNotifier) { socketNotifier->setEnabled(false); + socketNotifier->deleteLater(); + socketNotifier = nullptr; } if (signalPipe[0] >= 0) { @@ -133,6 +159,14 @@ namespace signalPipe[1] = -1; } #endif + +#ifdef Q_OS_WIN + if (windowsFilter) { + QCoreApplication::instance()->removeNativeEventFilter(windowsFilter); + delete windowsFilter; + windowsFilter = nullptr; + } +#endif } } @@ -147,12 +181,13 @@ void OsSignalHandler::setup() initialized = true; -#if (defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)) || defined(Q_OS_MACX) +#if (defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID)) || defined(Q_OS_MACOS) setupUnixSignalHandler(); #endif #ifdef Q_OS_WIN - SetConsoleCtrlHandler(consoleHandler, TRUE); + windowsFilter = new WindowsCloseFilter(); + QCoreApplication::instance()->installNativeEventFilter(windowsFilter); #endif QObject::connect(QCoreApplication::instance(), &QCoreApplication::aboutToQuit, [] { cleanupUnixSignalHandler(); }); diff --git a/deploy/build_windows.bat b/deploy/build_windows.bat index 628957568..5d56dcb59 100644 --- a/deploy/build_windows.bat +++ b/deploy/build_windows.bat @@ -8,6 +8,21 @@ set PATH=%QT_BIN_DIR:"=%;%PATH% echo "Using Qt in %QT_BIN_DIR%" echo "Using QIF in %QIF_BIN_DIR%" +echo "Using WiX in %WIX_BIN_DIR%" + +if "%WIX_BIN_DIR%"=="" ( + echo "WIX_BIN_DIR is not set" + exit /b 1 +) + +set WIX_BIN_DIR_UNQUOTED=%WIX_BIN_DIR:"=% + +set WIX_CLI=%WIX_BIN_DIR_UNQUOTED%\wix.exe + +if not exist "%WIX_CLI%" ( + echo "WiX CLI (wix.exe) was not found in %WIX_BIN_DIR%" + exit /b 1 +) REM Hold on to current directory set PROJECT_DIR=%cd% @@ -22,6 +37,8 @@ set PREBILT_DEPLOY_DATA_DIR=%PROJECT_DIR:"=%\client\3rd-prebuilt\deploy-prebuilt set DEPLOY_DATA_DIR=%SCRIPT_DIR:"=%\data\windows\x%BUILD_ARCH:"=% set INSTALLER_DATA_DIR=%WORK_DIR:"=%\installer\packages\%APP_DOMAIN:"=%\data set TARGET_FILENAME=%PROJECT_DIR:"=%\%APP_NAME:"=%_x%BUILD_ARCH:"=%.exe +set TARGET_MSI_FILENAME=%PROJECT_DIR:"=%\%APP_NAME:"=%_x%BUILD_ARCH:"=%.msi +set STAGE_DIR=%WORK_DIR:"=%\stage echo "Environment:" echo "WORK_DIR: %WORK_DIR%" @@ -32,10 +49,14 @@ echo "OUT_APP_DIR: %OUT_APP_DIR%" echo "DEPLOY_DATA_DIR: %DEPLOY_DATA_DIR%" echo "INSTALLER_DATA_DIR: %INSTALLER_DATA_DIR%" echo "TARGET_FILENAME: %TARGET_FILENAME%" +echo "TARGET_MSI_FILENAME: %TARGET_MSI_FILENAME%" +echo "STAGE_DIR: %STAGE_DIR%" echo "Cleanup..." rmdir /Q /S %WORK_DIR% del %TARGET_FILENAME% +del %TARGET_MSI_FILENAME% +rmdir /Q /S "%STAGE_DIR%" mkdir %WORK_DIR% @@ -56,6 +77,7 @@ mkdir "%OUT_APP_DIR%" copy "%WORK_DIR%\service\server\release\%APP_NAME%-service.exe" "%OUT_APP_DIR%" rem copy "%WORK_DIR%\client\%APP_FILENAME%" "%OUT_APP_DIR%" +copy /Y "%PROJECT_DIR%\client\images\app.ico" "%OUT_APP_DIR%\AmneziaVPN.ico" >nul echo "Signing exe" cd %OUT_APP_DIR% @@ -89,5 +111,38 @@ timeout 5 cd %PROJECT_DIR% signtool sign /v /n "Privacy Technologies OU" /fd sha256 /tr http://timestamp.comodoca.com/?td=sha256 /td sha256 "%TARGET_FILENAME%" -echo "Finished, see %TARGET_FILENAME%" +echo "Preparing staging directory for MSI..." +rmdir /Q /S "%STAGE_DIR%" +mkdir "%STAGE_DIR%" +xcopy "%OUT_APP_DIR%" "%STAGE_DIR%" /s /e /y /i /f >nul + +echo "Building MSI via CPack..." +rmdir /Q /S "%WORK_DIR%\_CPack_Packages" +cd %WORK_DIR% +cpack -G WIX -C Release --config "%WORK_DIR%\CPackConfig.cmake" +if exist "%WORK_DIR%\_CPack_Packages\win64\WIX\wix.log" ( + echo --------------------------------------------- + echo Contents of wix.log: + type "%WORK_DIR%\_CPack_Packages\win64\WIX\wix.log" + echo --------------------------------------------- +) +if %errorlevel% neq 0 exit /b %errorlevel% + +set GENERATED_MSI= +for /f "delims=" %%i in ('dir /b /a:-d /o:-d "%WORK_DIR%\*.msi"') do ( + if not defined GENERATED_MSI set GENERATED_MSI=%WORK_DIR%\%%i +) + +if "%GENERATED_MSI%"=="" ( + echo "Failed to locate generated MSI package" + exit /b 1 +) + +copy /Y "%GENERATED_MSI%" "%TARGET_MSI_FILENAME%" +if %errorlevel% neq 0 exit /b %errorlevel% + +cd %PROJECT_DIR% +signtool sign /v /n "Privacy Technologies OU" /fd sha256 /tr http://timestamp.comodoca.com/?td=sha256 /td sha256 "%TARGET_MSI_FILENAME%" + +echo "Finished, see %TARGET_FILENAME% and %TARGET_MSI_FILENAME%" exit 0 diff --git a/deploy/installer/wix/close_client_patch.xml b/deploy/installer/wix/close_client_patch.xml new file mode 100644 index 000000000..3d973532f --- /dev/null +++ b/deploy/installer/wix/close_client_patch.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/deploy/installer/wix/service_install_patch.xml b/deploy/installer/wix/service_install_patch.xml new file mode 100644 index 000000000..7b334c3d7 --- /dev/null +++ b/deploy/installer/wix/service_install_patch.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file