diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 2f8405061..d7c0c28b7 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -69,6 +69,7 @@ endif() qt6_add_resources(QRC ${QRC} ${CMAKE_CURRENT_LIST_DIR}/images/images.qrc ${CMAKE_CURRENT_LIST_DIR}/images/flagKit.qrc + ${CMAKE_CURRENT_LIST_DIR}/client_scripts/clientScripts.qrc ${CMAKE_CURRENT_LIST_DIR}/ui/qml/qml.qrc ${CMAKE_CURRENT_LIST_DIR}/server_scripts/serverScripts.qrc ) diff --git a/client/client_scripts/clientScripts.qrc b/client/client_scripts/clientScripts.qrc new file mode 100644 index 000000000..1c0ba9909 --- /dev/null +++ b/client/client_scripts/clientScripts.qrc @@ -0,0 +1,6 @@ + + + linux_installer.sh + mac_installer.sh + + diff --git a/client/client_scripts/linux_installer.sh b/client/client_scripts/linux_installer.sh new file mode 100644 index 000000000..f2232bc4c --- /dev/null +++ b/client/client_scripts/linux_installer.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +EXTRACT_DIR="$1" +INSTALLER_PATH="$2" + +# Create and clean extract directory +rm -rf "$EXTRACT_DIR" +mkdir -p "$EXTRACT_DIR" + +# Extract TAR archive +tar -xf "$INSTALLER_PATH" -C "$EXTRACT_DIR" +if [ $? -ne 0 ]; then + echo 'Failed to extract TAR archive' + exit 1 +fi + +# Find and run installer +INSTALLER=$(find "$EXTRACT_DIR" -type f -executable) +if [ -z "$INSTALLER" ]; then + echo 'Installer not found' + exit 1 +fi + +"$INSTALLER" +EXIT_CODE=$? + +# Cleanup +rm -rf "$EXTRACT_DIR" +exit $EXIT_CODE \ No newline at end of file diff --git a/client/client_scripts/mac_installer.sh b/client/client_scripts/mac_installer.sh new file mode 100644 index 000000000..41fd504d0 --- /dev/null +++ b/client/client_scripts/mac_installer.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +EXTRACT_DIR="$1" +INSTALLER_PATH="$2" + +set -e + +echo "[AmneziaVPN] Installer package: $INSTALLER_PATH" + +if [ ! -f "$INSTALLER_PATH" ]; then + echo "[AmneziaVPN] ERROR: Installer package not found: $INSTALLER_PATH" + exit 1 +fi + +PKG_PATH="$INSTALLER_PATH" +echo "[AmneziaVPN] Using PKG: $PKG_PATH" + +# Optional: basic signature/gatekeeper checks (non-fatal) +if command -v pkgutil >/dev/null 2>&1; then + pkgutil --check-signature "$PKG_PATH" || true +fi +if command -v spctl >/dev/null 2>&1; then + spctl -a -vvv -t install "$PKG_PATH" || true +fi + +# Run installer with admin privileges via AppleScript (prompts for password) +echo "[AmneziaVPN] Running installer..." +OSA_CMD='do shell script "/usr/sbin/installer -pkg '"$PKG_PATH"' -target /" with administrator privileges' +osascript -e "$OSA_CMD" + +STATUS=$? +if [ $STATUS -ne 0 ]; then + echo "[AmneziaVPN] ERROR: installer exited with status $STATUS" + exit $STATUS +fi + +echo "[AmneziaVPN] Cleaning up..." +rm -f "$INSTALLER_PATH" || true +rm -rf "$EXTRACT_DIR" 2>/dev/null || true + +echo "[AmneziaVPN] Installation completed successfully" +exit 0 diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake index f348ec8cd..497757757 100644 --- a/client/cmake/sources.cmake +++ b/client/cmake/sources.cmake @@ -45,6 +45,7 @@ set(HEADERS ${HEADERS} ${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.h ${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.h ${CLIENT_ROOT_DIR}/core/controllers/api/newsController.h + ${CLIENT_ROOT_DIR}/core/controllers/updateController.h ${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.h ${CLIENT_ROOT_DIR}/core/repositories/secureAppSettingsRepository.h ${CLIENT_ROOT_DIR}/core/protocols/qmlRegisterProtocols.h @@ -119,6 +120,7 @@ set(SOURCES ${SOURCES} ${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/newsController.cpp + ${CLIENT_ROOT_DIR}/core/controllers/updateController.cpp ${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.cpp ${CLIENT_ROOT_DIR}/core/repositories/secureAppSettingsRepository.cpp ${CLIENT_ROOT_DIR}/ui/utils/qAutoStart.cpp diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index 5f6b5e1c5..ab0840a97 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -346,13 +346,8 @@ ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userC appendProtocolDataToApiPayload(serviceProtocol, protocolData, apiPayload); apiPayload[apiDefs::key::transactionId] = transactionId; - GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(), - m_appSettingsRepository->isDevGatewayEnv(), - apiDefs::requestTimeoutMsecs, - m_appSettingsRepository->isStrictKillSwitchEnabled()); - QByteArray responseBody; - ErrorCode errorCode = gatewayController.post(QString("%1v1/subscriptions"), apiPayload, responseBody); + ErrorCode errorCode = executeRequest(QString("%1v1/subscriptions"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError) { return errorCode; } @@ -365,6 +360,9 @@ ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userC return ErrorCode::ApiPurchaseError; } + QString normalizedKey = key; + normalizedKey.replace(QStringLiteral("vpn://"), QString()); + // Check if server with this VPN key already exists for (int i = 0; i < m_serversRepository->serversCount(); ++i) { ServerConfig existingServerConfig = m_serversRepository->server(i); @@ -376,7 +374,8 @@ ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userC const ApiV2ServerConfig* apiV2 = existingServerConfig.as(); existingVpnKey = apiV2 ? apiV2->vpnKey() : QString(); } - if (existingVpnKey == key) { + existingVpnKey.replace(QStringLiteral("vpn://"), QString()); + if (!existingVpnKey.isEmpty() && existingVpnKey == normalizedKey) { if (duplicateServerIndex) { *duplicateServerIndex = i; } @@ -385,9 +384,6 @@ ErrorCode SubscriptionController::importServiceFromAppStore(const QString &userC } } - QString normalizedKey = key; - normalizedKey.replace(QStringLiteral("vpn://"), QString()); - QByteArray configString = QByteArray::fromBase64(normalizedKey.toUtf8(), QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals); QByteArray configUncompressed = qUncompress(configString); if (!configUncompressed.isEmpty()) { @@ -437,6 +433,7 @@ ErrorCode SubscriptionController::updateServiceFromGateway(int serverIndex, cons if (!apiV2) { return ErrorCode::InternalError; } + const bool isTestPurchase = apiV2->apiConfig.isTestPurchase; QString serviceProtocol = apiV2->serviceProtocol(); ProtocolData protocolData = generateProtocolData(serviceProtocol); @@ -459,7 +456,7 @@ ErrorCode SubscriptionController::updateServiceFromGateway(int serverIndex, cons } QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody); + ErrorCode errorCode = executeRequest(QString("%1v1/config"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError) { if (errorCode == ErrorCode::ApiSubscriptionExpiredError && !apiV2->apiConfig.isInAppPurchase) { ServerConfig expiredServerConfig = serverConfigModel; @@ -508,7 +505,7 @@ ErrorCode SubscriptionController::updateServiceFromGateway(int serverIndex, cons return ErrorCode::NoError; } -ErrorCode SubscriptionController::deactivateDevice(int serverIndex, bool isRemoveEvent) +ErrorCode SubscriptionController::deactivateDevice(int serverIndex) { ServerConfig serverConfigModel = m_serversRepository->server(serverIndex); @@ -538,8 +535,9 @@ ErrorCode SubscriptionController::deactivateDevice(int serverIndex, bool isRemov QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + const bool isTestPurchase = apiV2->apiConfig.isTestPurchase; QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/revoke_config"), apiPayload, responseBody); + ErrorCode errorCode = executeRequest(QString("%1v1/revoke_config"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError && errorCode != ErrorCode::ApiNotFoundError) { return errorCode; } @@ -581,8 +579,9 @@ ErrorCode SubscriptionController::deactivateExternalDevice(int serverIndex, cons QJsonObject apiPayload = gatewayRequestData.toJsonObject(); + const bool isTestPurchase = apiV2->apiConfig.isTestPurchase; QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/revoke_config"), apiPayload, responseBody); + ErrorCode errorCode = executeRequest(QString("%1v1/revoke_config"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError && errorCode != ErrorCode::ApiNotFoundError) { return errorCode; } @@ -609,6 +608,7 @@ ErrorCode SubscriptionController::exportNativeConfig(int serverIndex, const QStr if (!apiV2) { return ErrorCode::InternalError; } + const bool isTestPurchase = apiV2->apiConfig.isTestPurchase; QString protocol = configKey::awg; ProtocolData protocolData = generateProtocolData(protocol); @@ -627,7 +627,7 @@ ErrorCode SubscriptionController::exportNativeConfig(int serverIndex, const QStr appendProtocolDataToApiPayload(protocol, protocolData, apiPayload); QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/native_config"), apiPayload, responseBody); + ErrorCode errorCode = executeRequest(QString("%1v1/native_config"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError) { return errorCode; } @@ -650,6 +650,7 @@ ErrorCode SubscriptionController::revokeNativeConfig(int serverIndex, const QStr if (!apiV2) { return ErrorCode::InternalError; } + const bool isTestPurchase = apiV2->apiConfig.isTestPurchase; QString protocol = configKey::awg; QJsonObject authDataJson = apiV2->authData.toJson(); @@ -666,7 +667,7 @@ ErrorCode SubscriptionController::revokeNativeConfig(int serverIndex, const QStr QJsonObject apiPayload = gatewayRequestData.toJsonObject(); QByteArray responseBody; - ErrorCode errorCode = executeRequest(QString("%1v1/revoke_native_config"), apiPayload, responseBody); + ErrorCode errorCode = executeRequest(QString("%1v1/revoke_native_config"), apiPayload, responseBody, isTestPurchase); if (errorCode != ErrorCode::NoError && errorCode != ErrorCode::ApiNotFoundError) { return errorCode; } @@ -760,7 +761,7 @@ ErrorCode SubscriptionController::prepareVpnKeyExport(int serverIndex, QString & ErrorCode SubscriptionController::validateAndUpdateConfig(int serverIndex, bool hasInstalledContainers) { ServerConfig serverConfigModel = m_serversRepository->server(serverIndex); - + apiDefs::ConfigSource configSource; if (serverConfigModel.isApiV1()) { configSource = apiDefs::ConfigSource::Telegram; @@ -774,11 +775,11 @@ ErrorCode SubscriptionController::validateAndUpdateConfig(int serverIndex, bool removeApiConfig(serverIndex); return updateServiceFromTelegram(serverIndex); } else if (configSource == apiDefs::ConfigSource::AmneziaGateway && !hasInstalledContainers) { - return updateServiceFromGateway(serverIndex, "", false); + return updateServiceFromGateway(serverIndex, "", true); } else if (configSource && isApiKeyExpired(serverIndex)) { qDebug() << "attempt to update api config by expires_at event"; if (configSource == apiDefs::ConfigSource::AmneziaGateway) { - return updateServiceFromGateway(serverIndex, "", false); + return updateServiceFromGateway(serverIndex, "", true); } else { removeApiConfig(serverIndex); return updateServiceFromTelegram(serverIndex); @@ -1076,6 +1077,7 @@ QFuture> SubscriptionController::getRenewalLink(int se QJsonObject apiPayload = gatewayRequestData.toJsonObject(); apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); + apiPayload[apiDefs::key::subscriptionStatus] = getSubscriptionStatusForRenewal(apiV2->apiConfig); auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), @@ -1095,11 +1097,7 @@ QFuture> SubscriptionController::getRenewalLink(int se QJsonObject responseJson = QJsonDocument::fromJson(responseBody).object(); const QString url = responseJson.value("renewal_url").toString(); - if (url.isEmpty()) { - promise->addResult(qMakePair(ErrorCode::InternalError, QString())); - } else { - promise->addResult(qMakePair(ErrorCode::NoError, url)); - } + promise->addResult(qMakePair(ErrorCode::NoError, url)); promise->finish(); }); watcher->setFuture(postFuture); diff --git a/client/core/controllers/api/subscriptionController.h b/client/core/controllers/api/subscriptionController.h index 85f4d47a9..172708544 100644 --- a/client/core/controllers/api/subscriptionController.h +++ b/client/core/controllers/api/subscriptionController.h @@ -73,7 +73,7 @@ public: ErrorCode updateServiceFromGateway(int serverIndex, const QString &newCountryCode, bool isConnectEvent); - ErrorCode deactivateDevice(int serverIndex, bool isRemoveEvent); + ErrorCode deactivateDevice(int serverIndex); ErrorCode deactivateExternalDevice(int serverIndex, const QString &uuid, const QString &serverCountryCode); diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 4e95d5c70..227850b6d 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -146,6 +146,7 @@ void CoreController::initCoreControllers() m_servicesCatalogController = new ServicesCatalogController(m_appSettingsRepository); m_subscriptionController = new SubscriptionController(m_serversRepository, m_appSettingsRepository); m_newsController = new NewsController(m_appSettingsRepository, m_serversController); + m_updateController = new UpdateController(m_appSettingsRepository, this); m_installController = new InstallController(m_serversRepository, m_appSettingsRepository, this); m_exportController = new ExportController(m_serversRepository, m_appSettingsRepository, this); @@ -212,6 +213,9 @@ void CoreController::initControllers() m_apiNewsUiController = new ApiNewsUiController(m_newsModel, m_newsController, this); setQmlContextProperty("ApiNewsController", m_apiNewsUiController); + + m_updateUiController = new UpdateUiController(m_updateController, this); + setQmlContextProperty("UpdateController", m_updateUiController); } void CoreController::initAndroidController() diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 1fb178f0f..0d77c5167 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -26,6 +26,7 @@ #include "ui/controllers/ipSplitTunnelingUiController.h" #include "ui/controllers/systemController.h" #include "ui/controllers/languageUiController.h" +#include "ui/controllers/updateUiController.h" #include "ui/controllers/api/servicesCatalogUiController.h" #include "core/controllers/serversController.h" @@ -39,6 +40,7 @@ #include "core/controllers/selfhosted/installController.h" #include "core/controllers/settingsController.h" #include "core/controllers/connectionController.h" +#include "core/controllers/updateController.h" #include "core/repositories/secureServersRepository.h" #include "core/repositories/secureAppSettingsRepository.h" @@ -159,6 +161,7 @@ private: AppSplitTunnelingUiController* m_appSplitTunnelingUiController; AllowedDnsUiController* m_allowedDnsUiController; LanguageUiController* m_languageUiController; + UpdateUiController* m_updateUiController; SubscriptionUiController* m_subscriptionUiController; ApiNewsUiController* m_apiNewsUiController; @@ -173,6 +176,7 @@ private: ServicesCatalogController* m_servicesCatalogController; SubscriptionController* m_subscriptionController; NewsController* m_newsController; + UpdateController* m_updateController; InstallController* m_installController; ExportController* m_exportController; ConnectionController* m_connectionController; diff --git a/client/core/controllers/coreSignalHandlers.cpp b/client/core/controllers/coreSignalHandlers.cpp index f68eb8f91..449a8af1d 100644 --- a/client/core/controllers/coreSignalHandlers.cpp +++ b/client/core/controllers/coreSignalHandlers.cpp @@ -20,6 +20,7 @@ #include "ui/controllers/selfhosted/installUiController.h" #include "ui/controllers/importUiController.h" #include "ui/controllers/api/subscriptionUiController.h" +#include "ui/controllers/updateUiController.h" #include "ui/models/serversModel.h" #include "core/controllers/serversController.h" #include "core/controllers/ipSplitTunnelingController.h" @@ -83,6 +84,7 @@ void CoreSignalHandlers::initAllHandlers() initIosImportHandler(); initIosSettingsHandler(); initNotificationHandler(); + initUpdateFoundHandler(); } void CoreSignalHandlers::initErrorMessagesHandler() @@ -410,3 +412,19 @@ void CoreSignalHandlers::initNotificationHandler() #endif } +void CoreSignalHandlers::initUpdateFoundHandler() +{ +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + connect(m_coreController->m_apiNewsUiController, &ApiNewsUiController::fetchNewsFinished, m_coreController->m_updateUiController, + &UpdateUiController::checkForUpdates); + + connect(m_coreController->m_updateUiController, &UpdateUiController::updateFound, this, [this]() { + const QString version = m_coreController->m_updateUiController->getVersion(); + const QString updateId = version.isEmpty() ? QStringLiteral("update") : QStringLiteral("update-%1").arg(version); + m_coreController->m_newsModel->setUpdateNotification( + updateId, m_coreController->m_updateUiController->getHeaderText(), m_coreController->m_updateUiController->getChangelogText()); + emit m_coreController->m_pageController->showChangelogDrawer(); + }); +#endif +} + diff --git a/client/core/controllers/coreSignalHandlers.h b/client/core/controllers/coreSignalHandlers.h index 33e567ce8..51f1d2f1d 100644 --- a/client/core/controllers/coreSignalHandlers.h +++ b/client/core/controllers/coreSignalHandlers.h @@ -40,6 +40,7 @@ private: void initIosImportHandler(); void initIosSettingsHandler(); void initNotificationHandler(); + void initUpdateFoundHandler(); CoreController* m_coreController; }; diff --git a/client/core/controllers/updateController.cpp b/client/core/controllers/updateController.cpp new file mode 100644 index 000000000..24009705e --- /dev/null +++ b/client/core/controllers/updateController.cpp @@ -0,0 +1,391 @@ +#include "updateController.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "amneziaApplication.h" +#include "logger.h" +#include "version.h" +#include "core/controllers/gatewayController.h" +#include "core/utils/constants/apiKeys.h" +#include "core/utils/errorStrings.h" +#include "core/utils/selfhosted/scriptsRegistry.h" + +namespace +{ + Logger logger("UpdateController"); + +#if defined(Q_OS_WINDOWS) + const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_x64.exe"); + const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN_installer.exe"; +#elif defined(Q_OS_MACOS) + const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_macos.pkg"); + const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.pkg"; +#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) + const QLatin1String kInstallerRemoteFileNamePattern("AmneziaVPN_%1_linux_x64.tar"); + const QString kInstallerLocalPath = QStandardPaths::writableLocation(QStandardPaths::TempLocation) + "/AmneziaVPN.tar"; +#endif +} + +UpdateController::UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent) + : QObject(parent), m_appSettingsRepository(appSettingsRepository) +{ +} + +QString UpdateController::getRawChangelogText() const +{ + return m_changelogText; +} + +QString UpdateController::getReleaseDate() const +{ + return m_releaseDate; +} + +QString UpdateController::getVersion() const +{ + return m_version; +} + +void UpdateController::checkForUpdates() +{ + if (m_updateCheckRunning || !m_appSettingsRepository) { + return; + } + m_updateCheckRunning = true; + + fetchGatewayUrl(); +} + +void UpdateController::finishUpdateCheck() +{ + m_updateCheckRunning = false; +} + +void UpdateController::doGetAsync(const QString &endpoint, std::function onDone) +{ + QString fullUrl = m_baseUrl + endpoint; + + QNetworkRequest req; + req.setTransferTimeout(7000); + req.setUrl(QUrl(fullUrl)); + + QNetworkReply *reply = amnApp->networkManager()->get(req); + setupNetworkErrorHandling(reply, endpoint); + + QObject::connect(reply, &QNetworkReply::finished, this, [this, reply, endpoint, onDone]() { + const bool ok = (reply->error() == QNetworkReply::NoError); + QByteArray data; + if (ok) { + data = reply->readAll(); + } else { + handleNetworkError(reply, endpoint); + } + reply->deleteLater(); + onDone(ok, data); + }); +} + +void UpdateController::fetchGatewayUrl() +{ + auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(), + m_appSettingsRepository->isDevGatewayEnv(), + 7000, + m_appSettingsRepository->isStrictKillSwitchEnabled()); + + QJsonObject apiPayload; + apiPayload[apiDefs::key::cliVersion] = QString(APP_VERSION); + apiPayload[apiDefs::key::osVersion] = QSysInfo::productType(); + apiPayload[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true); + + // Workaround: wait before contacting gateway to avoid rate limit triggered by other requests (news etc.) + QTimer::singleShot(1000, this, [this, gatewayController, apiPayload]() { + gatewayController->postAsync(QStringLiteral("%1v1/updater_endpoint"), apiPayload) + .then(this, [this](QPair result) { + auto [err, gatewayResponse] = result; + if (err != ErrorCode::NoError) { + logger.error() << errorString(err); + finishUpdateCheck(); + return; + } + + QJsonObject gatewayData = QJsonDocument::fromJson(gatewayResponse).object(); + + QString baseUrl = gatewayData.value("url").toString(); + if (baseUrl.endsWith('/')) { + baseUrl.chop(1); + } + m_baseUrl = baseUrl; + + fetchVersionInfo(); + }); + }); +} + +void UpdateController::fetchVersionInfo() +{ + doGetAsync("/VERSION", [this](bool ok, QByteArray data) { + if (!ok) { + finishUpdateCheck(); + return; + } + m_version = QString::fromUtf8(data).trimmed(); + + if (!isNewVersionAvailable()) { + finishUpdateCheck(); + return; + } + fetchChangelog(); + }); +} + +void UpdateController::fetchChangelog() +{ + doGetAsync("/CHANGELOG", [this](bool ok, QByteArray data) { + if (!ok) { + m_changelogText.clear(); + } else { + m_changelogText = QString::fromUtf8(data); + } + fetchReleaseDate(); + }); +} + +void UpdateController::fetchReleaseDate() +{ + doGetAsync("/RELEASE_DATE", [this](bool ok, QByteArray data) { + if (ok) { + m_releaseDate = QString::fromUtf8(data).trimmed(); + } else { + m_releaseDate = QString(); + } + + m_downloadUrl = composeDownloadUrl(); + emit updateFound(); + finishUpdateCheck(); + }); +} + +bool UpdateController::isNewVersionAvailable() const +{ + auto currentVersion = QVersionNumber::fromString(QString(APP_VERSION)); + auto newVersion = QVersionNumber::fromString(m_version); + return newVersion > currentVersion; +} + +void UpdateController::setupNetworkErrorHandling(QNetworkReply* reply, const QString& operation) +{ + QObject::connect(reply, &QNetworkReply::errorOccurred, [reply, operation](QNetworkReply::NetworkError error) { + logger.error() << QString("Network error occurred while fetching %1: %2 %3") + .arg(operation, reply->errorString(), QString::number(error)); + }); + + QObject::connect(reply, &QNetworkReply::sslErrors, [operation](const QList &errors) { + QStringList errorStrings; + for (const QSslError &err : errors) { + errorStrings << err.errorString(); + } + logger.error() << QString("SSL errors while fetching %1: %2").arg(operation, errorStrings.join("; ")); + }); +} + +void UpdateController::handleNetworkError(QNetworkReply* reply, const QString& operation) +{ + if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError + || reply->error() == QNetworkReply::NetworkError::TimeoutError) { + logger.error() << errorString(ErrorCode::ApiConfigTimeoutError); + } else { + QString err = reply->errorString(); + logger.error() << "Network error code:" << QString::number(static_cast(reply->error())); + logger.error() << "Error message:" << err; + logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + logger.error() << errorString(ErrorCode::ApiConfigDownloadError); + } +} + +QString UpdateController::composeDownloadUrl() const +{ +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + const QString fileName = QString(kInstallerRemoteFileNamePattern).arg(m_version); + return m_baseUrl + "/" + fileName; +#else + return QString(); +#endif +} + +void UpdateController::runInstaller() +{ +#if !defined(Q_OS_ANDROID) && !defined(Q_OS_IOS) + if (m_downloadUrl.isEmpty()) { + logger.error() << "Download URL is empty"; + return; + } + + QNetworkRequest request; + request.setTransferTimeout(30000); + request.setUrl(m_downloadUrl); + + QNetworkReply *reply = amnApp->networkManager()->get(request); + + QObject::connect(reply, &QNetworkReply::finished, [this, reply]() { + if (reply->error() == QNetworkReply::NoError) { + QFile file(kInstallerLocalPath); + if (!file.open(QIODevice::WriteOnly)) { + logger.error() << "Failed to open installer file for writing:" << kInstallerLocalPath << "Error:" << file.errorString(); + reply->deleteLater(); + return; + } + + if (file.write(reply->readAll()) == -1) { + logger.error() << "Failed to write installer data to file:" << kInstallerLocalPath << "Error:" << file.errorString(); + file.close(); + reply->deleteLater(); + return; + } + + file.close(); + + #if defined(Q_OS_WINDOWS) + runWindowsInstaller(kInstallerLocalPath); + #elif defined(Q_OS_MACOS) + runMacInstaller(kInstallerLocalPath); + #elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) + runLinuxInstaller(kInstallerLocalPath); + #endif + } else { + if (reply->error() == QNetworkReply::NetworkError::OperationCanceledError + || reply->error() == QNetworkReply::NetworkError::TimeoutError) { + logger.error() << errorString(ErrorCode::ApiConfigTimeoutError); + } else { + QString err = reply->errorString(); + logger.error() << QString::fromUtf8(reply->readAll()); + logger.error() << "Network error code:" << QString::number(static_cast(reply->error())); + logger.error() << "Error message:" << err; + logger.error() << "HTTP status:" << reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + logger.error() << errorString(ErrorCode::ApiConfigDownloadError); + } + } + reply->deleteLater(); + }); +#endif +} + +#if defined(Q_OS_WINDOWS) +int UpdateController::runWindowsInstaller(const QString &installerPath) +{ + qint64 pid; + bool success = QProcess::startDetached(installerPath, QStringList(), QString(), &pid); + + if (success) { + logger.info() << "Installation process started with PID:" << pid; + } else { + logger.error() << "Failed to start installation process"; + return -1; + } + + return 0; +} +#endif + +#if defined(Q_OS_MACOS) +int UpdateController::runMacInstaller(const QString &installerPath) +{ + // Create temporary directory for extraction + QTemporaryDir extractDir; + extractDir.setAutoRemove(false); + if (!extractDir.isValid()) { + logger.error() << "Failed to create temporary directory"; + return -1; + } + logger.info() << "Temporary directory created:" << extractDir.path(); + + // Create script file in the temporary directory + QString scriptPath = extractDir.path() + "/mac_installer.sh"; + QFile scriptFile(scriptPath); + if (!scriptFile.open(QIODevice::WriteOnly)) { + logger.error() << "Failed to create script file"; + return -1; + } + + // Get script content from registry + QString scriptContent = amnezia::scriptData(amnezia::ClientScriptType::mac_installer); + if (scriptContent.isEmpty()) { + logger.error() << "macOS installer script content is empty"; + scriptFile.close(); + return -1; + } + + scriptFile.write(scriptContent.toUtf8()); + scriptFile.close(); + logger.info() << "Script file created:" << scriptPath; + + // Make script executable + QFile::setPermissions(scriptPath, QFile::permissions(scriptPath) | QFile::ExeUser); + + // Start detached process + qint64 pid; + bool success = + QProcess::startDetached("/bin/bash", QStringList() << scriptPath << extractDir.path() << installerPath, extractDir.path(), &pid); + + if (success) { + logger.info() << "Installation process started with PID:" << pid; + } else { + logger.error() << "Failed to start installation process"; + return -1; + } + + return 0; +} +#endif + +#if defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) +int UpdateController::runLinuxInstaller(const QString &installerPath) +{ + // Create temporary directory for extraction + QTemporaryDir extractDir; + extractDir.setAutoRemove(false); + if (!extractDir.isValid()) { + logger.error() << "Failed to create temporary directory"; + return -1; + } + logger.info() << "Temporary directory created:" << extractDir.path(); + + // Create script file in the temporary directory + QString scriptPath = extractDir.path() + "/installer.sh"; + QFile scriptFile(scriptPath); + if (!scriptFile.open(QIODevice::WriteOnly)) { + logger.error() << "Failed to create script file"; + return -1; + } + + // Get script content from registry + QString scriptContent = amnezia::scriptData(amnezia::ClientScriptType::linux_installer); + scriptFile.write(scriptContent.toUtf8()); + scriptFile.close(); + logger.info() << "Script file created:" << scriptPath; + + // Make script executable + QFile::setPermissions(scriptPath, QFile::permissions(scriptPath) | QFile::ExeUser); + + // Start detached process + qint64 pid; + bool success = + QProcess::startDetached("/bin/bash", QStringList() << scriptPath << extractDir.path() << installerPath, extractDir.path(), &pid); + + if (success) { + logger.info() << "Installation process started with PID:" << pid; + } else { + logger.error() << "Failed to start installation process"; + return -1; + } + + return 0; +} +#endif + + diff --git a/client/core/controllers/updateController.h b/client/core/controllers/updateController.h new file mode 100644 index 000000000..97ad361df --- /dev/null +++ b/client/core/controllers/updateController.h @@ -0,0 +1,57 @@ +#ifndef UPDATECONTROLLER_H +#define UPDATECONTROLLER_H + +#include +#include +#include + +#include "core/repositories/secureAppSettingsRepository.h" + +class UpdateController : public QObject +{ + Q_OBJECT +public: + explicit UpdateController(SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr); + + QString getRawChangelogText() const; + QString getReleaseDate() const; + QString getVersion() const; + +public slots: + void checkForUpdates(); + void runInstaller(); + +signals: + void updateFound(); + +private: + void finishUpdateCheck(); + void fetchGatewayUrl(); + void fetchVersionInfo(); + void fetchChangelog(); + void fetchReleaseDate(); + void doGetAsync(const QString &endpoint, std::function onDone); + bool isNewVersionAvailable() const; + void setupNetworkErrorHandling(QNetworkReply* reply, const QString& operation); + void handleNetworkError(QNetworkReply* reply, const QString& operation); + QString composeDownloadUrl() const; + + SecureAppSettingsRepository* m_appSettingsRepository; + + QString m_baseUrl; + QString m_changelogText; + QString m_version; + QString m_releaseDate; + QString m_downloadUrl; + bool m_updateCheckRunning = false; + +#if defined(Q_OS_WINDOWS) + int runWindowsInstaller(const QString &installerPath); +#elif defined(Q_OS_MACOS) + int runMacInstaller(const QString &installerPath); +#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) + int runLinuxInstaller(const QString &installerPath); +#endif +}; + +#endif // UPDATECONTROLLER_H diff --git a/client/core/models/api/apiConfig.h b/client/core/models/api/apiConfig.h index a2cff58d0..05ecda478 100644 --- a/client/core/models/api/apiConfig.h +++ b/client/core/models/api/apiConfig.h @@ -58,7 +58,7 @@ struct ApiConfig QString stackType; QString cliVersion; - bool isTestPurchase; + bool isTestPurchase = false; bool isInAppPurchase = false; bool subscriptionExpiredByServer = false; diff --git a/client/core/protocols/xrayProtocol.cpp b/client/core/protocols/xrayProtocol.cpp index d3d483334..893b8367e 100755 --- a/client/core/protocols/xrayProtocol.cpp +++ b/client/core/protocols/xrayProtocol.cpp @@ -1,16 +1,16 @@ #include "xrayProtocol.h" +#include "core/protocols/protocolUtils.h" #include "core/utils/constants/configKeys.h" #include "core/utils/ipcClient.h" #include "core/utils/networkUtilities.h" -#include "core/protocols/protocolUtils.h" #include "core/utils/serialization/serialization.h" #include "ipc.h" #include +#include #include #include -#include #include #include #include @@ -43,7 +43,17 @@ XrayProtocol::XrayProtocol(const QJsonObject &configuration, QObject *parent) : if (xrayConfiguration.isEmpty()) { xrayConfiguration = configuration.value(ProtocolUtils::key_proto_config_data(Proto::SSXray)).toObject(); } - m_xrayConfig = xrayConfiguration; + + if (xrayConfiguration.isEmpty()) { + qWarning() << "Xray config wrapper is empty"; + m_xrayConfig = {}; + } + + m_xrayConfig = QJsonDocument::fromJson(xrayConfiguration.value(amnezia::configKey::config).toString().toUtf8()).object(); + if (m_xrayConfig.isEmpty()) { + qWarning() << "Xray config string is not a valid JSON object"; + m_xrayConfig = {}; + } } XrayProtocol::~XrayProtocol() @@ -65,9 +75,9 @@ ErrorCode XrayProtocol::start() qCritical() << "EnsureInboundAuth failed:" << e.what(); return ErrorCode::InternalError; } - m_socksUser = creds.username; + m_socksUser = creds.username; m_socksPassword = creds.password; - m_socksPort = creds.port; + m_socksPort = creds.port; const QString xrayConfigStr = QJsonDocument(m_xrayConfig).toJson(QJsonDocument::Compact); if (xrayConfigStr.isEmpty()) { @@ -75,16 +85,16 @@ ErrorCode XrayProtocol::start() return ErrorCode::XrayExecutableCrashed; } - return IpcClient::withInterface([&](QSharedPointer iface) { - auto xrayStart = iface->xrayStart(xrayConfigStr); - if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) { - qCritical() << "Failed to start xray"; - return ErrorCode::XrayExecutableCrashed; - } - return startTun2Socks(); - }, [] () { - return ErrorCode::AmneziaServiceConnectionFailed; - }); + return IpcClient::withInterface( + [&](QSharedPointer iface) { + auto xrayStart = iface->xrayStart(xrayConfigStr); + if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) { + qCritical() << "Failed to start xray"; + return ErrorCode::XrayExecutableCrashed; + } + return startTun2Socks(); + }, + []() { return ErrorCode::AmneziaServiceConnectionFailed; }); } void XrayProtocol::stop() @@ -143,129 +153,135 @@ ErrorCode XrayProtocol::startTun2Socks() return ErrorCode::AmneziaServiceConnectionFailed; } - const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3") - .arg(m_socksUser, m_socksPassword, QString::number(m_socksPort)); + const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3").arg(m_socksUser, m_socksPassword, QString::number(m_socksPort)); m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks); - m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl}); + m_tun2socksProcess->setArguments({ "-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl }); - connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() { - auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput(); - if (!readAllStandardOutput.waitForFinished()) { - qWarning() << "Failed to read output from tun2socks"; - return; - } + connect( + m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, + [this]() { + auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput(); + if (!readAllStandardOutput.waitForFinished()) { + qWarning() << "Failed to read output from tun2socks"; + return; + } - const QString line = readAllStandardOutput.returnValue(); + const QString line = readAllStandardOutput.returnValue(); - if (!line.contains("[TCP]") && !line.contains("[UDP]")) - qDebug() << "[tun2socks]:" << line; - - if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) { - disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr); + if (!line.contains("[TCP]") && !line.contains("[UDP]")) + qDebug() << "[tun2socks]:" << line; - if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) { + if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) { + disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr); + + if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) { + stop(); + setLastError(res); + } else { + setConnectionState(Vpn::ConnectionState::Connected); + } + } + }, + Qt::QueuedConnection); + + connect( + m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::finished, this, + [this](int exitCode, QProcess::ExitStatus exitStatus) { + if (exitStatus == QProcess::ExitStatus::CrashExit) { + qCritical() << "Tun2socks process crashed!"; + } else { + qCritical() << QString("Tun2socks process was closed with %1 exit code").arg(exitCode); + } stop(); - setLastError(res); - } else { - setConnectionState(Vpn::ConnectionState::Connected); - } - } - }, Qt::QueuedConnection); - - connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::finished, this, [this](int exitCode, QProcess::ExitStatus exitStatus) { - if (exitStatus == QProcess::ExitStatus::CrashExit) { - qCritical() << "Tun2socks process crashed!"; - } else { - qCritical() << QString("Tun2socks process was closed with %1 exit code").arg(exitCode); - } - stop(); - setLastError(ErrorCode::Tun2SockExecutableCrashed); - }, Qt::QueuedConnection); + setLastError(ErrorCode::Tun2SockExecutableCrashed); + }, + Qt::QueuedConnection); m_tun2socksProcess->start(); return ErrorCode::NoError; } -ErrorCode XrayProtocol::setupRouting() { - return IpcClient::withInterface([this](QSharedPointer iface) -> ErrorCode { +ErrorCode XrayProtocol::setupRouting() +{ + return IpcClient::withInterface( + [this](QSharedPointer iface) -> ErrorCode { #ifdef Q_OS_WIN - const int inetAdapterIndex = NetworkUtilities::AdapterIndexTo(QHostAddress(m_remoteAddress)); + const int inetAdapterIndex = NetworkUtilities::AdapterIndexTo(QHostAddress(m_remoteAddress)); #endif - auto createTun = iface->createTun(tunName, amnezia::protocols::xray::defaultLocalAddr); - if (!createTun.waitForFinished() || !createTun.returnValue()) { - qCritical() << "Failed to assign IP address for TUN"; - return ErrorCode::InternalError; - } - - auto updateResolvers = iface->updateResolvers(tunName, m_dnsServers); - if (!updateResolvers.waitForFinished() || !updateResolvers.returnValue()) { - qCritical() << "Failed to set DNS resolvers for TUN"; - return ErrorCode::InternalError; - } - -#ifdef Q_OS_WIN - int vpnAdapterIndex = -1; - QList netInterfaces = QNetworkInterface::allInterfaces(); - for (auto& netInterface : netInterfaces) { - for (auto& address : netInterface.addressEntries()) { - if (m_vpnLocalAddress == address.ip().toString()) - vpnAdapterIndex = netInterface.index(); - } - } -#else - static const int vpnAdapterIndex = 0; -#endif - const bool killSwitchEnabled = QVariant(m_rawConfig.value(configKey::killSwitchOption).toString()).toBool(); - if (killSwitchEnabled) { - if (vpnAdapterIndex != -1) { - QJsonObject config = m_rawConfig; - config.insert("vpnServer", m_remoteAddress); - - auto enableKillSwitch = IpcClient::Interface()->enableKillSwitch(config, vpnAdapterIndex); - if (!enableKillSwitch.waitForFinished() || !enableKillSwitch.returnValue()) { - qCritical() << "Failed to enable killswitch"; + auto createTun = iface->createTun(tunName, amnezia::protocols::xray::defaultLocalAddr); + if (!createTun.waitForFinished() || !createTun.returnValue()) { + qCritical() << "Failed to assign IP address for TUN"; return ErrorCode::InternalError; } - } else - qWarning() << "Failed to get vpnAdapterIndex. Killswitch disabled"; - } - if (m_routeMode == amnezia::RouteMode::VpnAllSites) { - static const QStringList subnets = { "1.0.0.0/8", "2.0.0.0/7", "4.0.0.0/6", "8.0.0.0/5", "16.0.0.0/4", "32.0.0.0/3", "64.0.0.0/2", "128.0.0.0/1" }; - - auto routeAddList = iface->routeAddList(m_vpnGateway, subnets); - if (!routeAddList.waitForFinished() || routeAddList.returnValue() != subnets.count()) { - qCritical() << "Failed to set routes for TUN"; - return ErrorCode::InternalError; - } - } - - auto StopRoutingIpv6 = iface->StopRoutingIpv6(); - if (!StopRoutingIpv6.waitForFinished() || !StopRoutingIpv6.returnValue()) { - qCritical() << "Failed to disable IPv6 routing"; - return ErrorCode::InternalError; - } + auto updateResolvers = iface->updateResolvers(tunName, m_dnsServers); + if (!updateResolvers.waitForFinished() || !updateResolvers.returnValue()) { + qCritical() << "Failed to set DNS resolvers for TUN"; + return ErrorCode::InternalError; + } #ifdef Q_OS_WIN - if (inetAdapterIndex != -1 && vpnAdapterIndex != -1) { - QJsonObject config = m_rawConfig; - config.insert("inetAdapterIndex", inetAdapterIndex); - config.insert("vpnAdapterIndex", vpnAdapterIndex); - config.insert("vpnGateway", m_vpnGateway); - config.insert("vpnServer", m_remoteAddress); - - auto enablePeerTraffic = iface->enablePeerTraffic(config); - if (!enablePeerTraffic.waitForFinished() || !enablePeerTraffic.returnValue()) { - qCritical() << "Failed to enable peer traffic"; - return ErrorCode::InternalError; - } - } else - qWarning() << "Failed to get adapter indexes. Split-tunneling disabled"; + int vpnAdapterIndex = -1; + QList netInterfaces = QNetworkInterface::allInterfaces(); + for (auto &netInterface : netInterfaces) { + for (auto &address : netInterface.addressEntries()) { + if (m_vpnLocalAddress == address.ip().toString()) + vpnAdapterIndex = netInterface.index(); + } + } +#else + static const int vpnAdapterIndex = 0; #endif - return ErrorCode::NoError; - }, - [] () { - return ErrorCode::AmneziaServiceConnectionFailed; - }); + const bool killSwitchEnabled = QVariant(m_rawConfig.value(configKey::killSwitchOption).toString()).toBool(); + if (killSwitchEnabled) { + if (vpnAdapterIndex != -1) { + QJsonObject config = m_rawConfig; + config.insert("vpnServer", m_remoteAddress); + + auto enableKillSwitch = IpcClient::Interface()->enableKillSwitch(config, vpnAdapterIndex); + if (!enableKillSwitch.waitForFinished() || !enableKillSwitch.returnValue()) { + qCritical() << "Failed to enable killswitch"; + return ErrorCode::InternalError; + } + } else + qWarning() << "Failed to get vpnAdapterIndex. Killswitch disabled"; + } + + if (m_routeMode == amnezia::RouteMode::VpnAllSites) { + static const QStringList subnets = { "1.0.0.0/8", "2.0.0.0/7", "4.0.0.0/6", "8.0.0.0/5", + "16.0.0.0/4", "32.0.0.0/3", "64.0.0.0/2", "128.0.0.0/1" }; + + auto routeAddList = iface->routeAddList(m_vpnGateway, subnets); + if (!routeAddList.waitForFinished() || routeAddList.returnValue() != subnets.count()) { + qCritical() << "Failed to set routes for TUN"; + return ErrorCode::InternalError; + } + } + + auto StopRoutingIpv6 = iface->StopRoutingIpv6(); + if (!StopRoutingIpv6.waitForFinished() || !StopRoutingIpv6.returnValue()) { + qCritical() << "Failed to disable IPv6 routing"; + return ErrorCode::InternalError; + } + +#ifdef Q_OS_WIN + if (inetAdapterIndex != -1 && vpnAdapterIndex != -1) { + QJsonObject config = m_rawConfig; + config.insert("inetAdapterIndex", inetAdapterIndex); + config.insert("vpnAdapterIndex", vpnAdapterIndex); + config.insert("vpnGateway", m_vpnGateway); + config.insert("vpnServer", m_remoteAddress); + + auto enablePeerTraffic = iface->enablePeerTraffic(config); + if (!enablePeerTraffic.waitForFinished() || !enablePeerTraffic.returnValue()) { + qCritical() << "Failed to enable peer traffic"; + return ErrorCode::InternalError; + } + } else + qWarning() << "Failed to get adapter indexes. Split-tunneling disabled"; +#endif + return ErrorCode::NoError; + }, + []() { return ErrorCode::AmneziaServiceConnectionFailed; }); } diff --git a/client/core/utils/selfhosted/scriptsRegistry.cpp b/client/core/utils/selfhosted/scriptsRegistry.cpp index 03d329ee4..3ff409499 100644 --- a/client/core/utils/selfhosted/scriptsRegistry.cpp +++ b/client/core/utils/selfhosted/scriptsRegistry.cpp @@ -73,6 +73,15 @@ QString amnezia::scriptName(ProtocolScriptType type) } } +QString amnezia::scriptName(ClientScriptType type) +{ + switch (type) { + case ClientScriptType::linux_installer: return QLatin1String("linux_installer.sh"); + case ClientScriptType::mac_installer: return QLatin1String("mac_installer.sh"); + default: return QString(); + } +} + QString amnezia::scriptData(amnezia::SharedScriptType type) { QString fileName = QString(":/server_scripts/%1").arg(amnezia::scriptName(type)); @@ -101,6 +110,22 @@ QString amnezia::scriptData(amnezia::ProtocolScriptType type, DockerContainer co return data; } +QString amnezia::scriptData(ClientScriptType type) +{ + QString fileName = QString(":/client_scripts/%1").arg(amnezia::scriptName(type)); + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + qDebug() << "Warning: script missing" << fileName; + return ""; + } + QByteArray data = file.readAll(); + if (data.isEmpty()) { + qDebug() << "Warning: script is empty" << fileName; + } + data.replace("\r", ""); + return data; +} + amnezia::ScriptVars amnezia::genBaseVars(const ServerCredentials &credentials, DockerContainer container, const QString &primaryDns, diff --git a/client/core/utils/selfhosted/scriptsRegistry.h b/client/core/utils/selfhosted/scriptsRegistry.h index e10862777..26bb2f0e9 100644 --- a/client/core/utils/selfhosted/scriptsRegistry.h +++ b/client/core/utils/selfhosted/scriptsRegistry.h @@ -41,14 +41,21 @@ enum ProtocolScriptType { xray_template }; +enum ClientScriptType { + // Client-side scripts + linux_installer, + mac_installer +}; QString scriptFolder(DockerContainer container); QString scriptName(SharedScriptType type); QString scriptName(ProtocolScriptType type); +QString scriptName(ClientScriptType type); QString scriptData(SharedScriptType type); QString scriptData(ProtocolScriptType type, DockerContainer container); +QString scriptData(ClientScriptType type); ScriptVars genBaseVars(const ServerCredentials &credentials, DockerContainer container, diff --git a/client/ui/controllers/api/apiNewsUiController.cpp b/client/ui/controllers/api/apiNewsUiController.cpp index 899032e33..2b288c6b0 100644 --- a/client/ui/controllers/api/apiNewsUiController.cpp +++ b/client/ui/controllers/api/apiNewsUiController.cpp @@ -22,7 +22,7 @@ void ApiNewsUiController::fetchNews(bool showError) return; } - m_newsModel->updateModel(newsArray); + m_newsModel->setNewsList(newsArray); emit fetchNewsFinished(); }); } diff --git a/client/ui/controllers/api/servicesCatalogUiController.cpp b/client/ui/controllers/api/servicesCatalogUiController.cpp index 734bdd9ac..bf5fed650 100644 --- a/client/ui/controllers/api/servicesCatalogUiController.cpp +++ b/client/ui/controllers/api/servicesCatalogUiController.cpp @@ -23,6 +23,9 @@ bool ServicesCatalogUiController::fillAvailableServices() } m_apiServicesModel->updateModel(servicesData); + if (m_apiServicesModel->rowCount() > 0) { + m_apiServicesModel->setServiceIndex(0); + } return true; } diff --git a/client/ui/controllers/api/subscriptionUiController.cpp b/client/ui/controllers/api/subscriptionUiController.cpp index aa6934ddb..955301163 100644 --- a/client/ui/controllers/api/subscriptionUiController.cpp +++ b/client/ui/controllers/api/subscriptionUiController.cpp @@ -390,7 +390,8 @@ bool SubscriptionUiController::updateServiceFromGateway(const int serverIndex, c if (oldServerConfig.isApiV2()) { const ApiV2ServerConfig *oldApiV2 = oldServerConfig.as(); if (oldApiV2) { - wasSubscriptionExpired = oldApiV2->apiConfig.isSubscriptionExpired(); + wasSubscriptionExpired = oldApiV2->apiConfig.subscriptionExpiredByServer + || oldApiV2->apiConfig.isSubscriptionExpired(); } } @@ -411,8 +412,9 @@ bool SubscriptionUiController::updateServiceFromGateway(const int serverIndex, c } else { if (errorCode == ErrorCode::ApiSubscriptionExpiredError) { emit subscriptionExpiredOnServer(); + } else { + emit errorOccurred(errorCode); } - emit errorOccurred(errorCode); return false; } } @@ -435,14 +437,10 @@ bool SubscriptionUiController::updateServiceFromTelegram(const int serverIndex) } } -bool SubscriptionUiController::deactivateDevice(int serverIndex, const bool isRemoveEvent) +bool SubscriptionUiController::deactivateDevice(int serverIndex) { - - ErrorCode errorCode = m_subscriptionController->deactivateDevice(serverIndex, isRemoveEvent); + ErrorCode errorCode = m_subscriptionController->deactivateDevice(serverIndex); if (errorCode != ErrorCode::NoError) { - if (errorCode == ErrorCode::ApiSubscriptionExpiredError && isRemoveEvent) { - return true; - } emit errorOccurred(errorCode); return false; } @@ -469,7 +467,11 @@ void SubscriptionUiController::validateConfig() ErrorCode errorCode = m_subscriptionController->validateAndUpdateConfig(serverIndex, hasInstalledContainers); if (errorCode != ErrorCode::NoError) { - emit errorOccurred(errorCode); + if (errorCode == ErrorCode::ApiSubscriptionExpiredError) { + emit subscriptionExpiredOnServer(); + } else { + emit errorOccurred(errorCode); + } emit configValidated(false); return; } @@ -559,6 +561,9 @@ void SubscriptionUiController::getRenewalLink(int serverIndex) emit errorOccurred(errorCode); return; } + if (url.isEmpty()) { + return; + } emit renewalLinkReceived(url); }); watcher->setFuture(m_subscriptionController->getRenewalLink(serverIndex)); diff --git a/client/ui/controllers/api/subscriptionUiController.h b/client/ui/controllers/api/subscriptionUiController.h index 142335991..d4ed056d8 100644 --- a/client/ui/controllers/api/subscriptionUiController.h +++ b/client/ui/controllers/api/subscriptionUiController.h @@ -48,7 +48,7 @@ public slots: bool updateServiceFromGateway(const int serverIndex, const QString &newCountryCode, const QString &newCountryName, bool reloadServiceConfig = false); bool updateServiceFromTelegram(const int serverIndex); - bool deactivateDevice(int serverIndex, const bool isRemoveEvent); + bool deactivateDevice(int serverIndex); bool deactivateExternalDevice(int serverIndex, const QString &uuid, const QString &serverCountryCode); void validateConfig(); diff --git a/client/ui/controllers/qml/pageController.h b/client/ui/controllers/qml/pageController.h index df95fd7af..603e8a8f4 100644 --- a/client/ui/controllers/qml/pageController.h +++ b/client/ui/controllers/qml/pageController.h @@ -167,6 +167,7 @@ signals: void escapePressed(); void closeTopDrawer(); + void showChangelogDrawer(); void imeHeightChanged(int height); void safeAreaTopMarginChanged(); void safeAreaBottomMarginChanged(); diff --git a/client/ui/controllers/updateUiController.cpp b/client/ui/controllers/updateUiController.cpp new file mode 100644 index 000000000..1aaf6cd8a --- /dev/null +++ b/client/ui/controllers/updateUiController.cpp @@ -0,0 +1,84 @@ +#include "updateUiController.h" + +UpdateUiController::UpdateUiController(UpdateController* updateController, QObject *parent) + : QObject(parent), m_updateController(updateController) +{ + if (m_updateController) { + connect(m_updateController, &UpdateController::updateFound, this, &UpdateUiController::updateFound); + } +} + +QString UpdateUiController::getHeaderText() const +{ + if (!m_updateController) { + return QString(); + } + + const QString version = m_updateController->getVersion(); + const QString releaseDate = m_updateController->getReleaseDate(); + if (releaseDate.trimmed().isEmpty()) { + return tr("New version released: %1").arg(version); + } + + return tr("New version released: %1 (%2)").arg(version, releaseDate); +} + +QString UpdateUiController::getChangelogText() const +{ + if (!m_updateController) { + return QString(); + } + + const QString rawChangelog = m_updateController->getRawChangelogText(); + if (rawChangelog.isEmpty()) { + return tr("Failed to load changelog text"); + } + + QStringList lines = rawChangelog.split("\n"); + QStringList filteredChangeLogText; + bool add = false; + QString osSection; + +#ifdef Q_OS_WINDOWS + osSection = "### Windows"; +#elif defined(Q_OS_MACOS) + osSection = "### macOS"; +#elif defined(Q_OS_LINUX) && !defined(Q_OS_ANDROID) + osSection = "### Linux"; +#endif + + for (const QString &line : lines) { + if (line.startsWith("### General")) { + add = true; + } else if (line.startsWith("### ") && line != osSection) { + add = false; + } else if (line == osSection) { + add = true; + } + + if (add) { + filteredChangeLogText.append(line); + } + } + + return filteredChangeLogText.join("\n"); +} + +QString UpdateUiController::getVersion() const +{ + return m_updateController ? m_updateController->getVersion() : QString(); +} + +void UpdateUiController::checkForUpdates() +{ + if (m_updateController) { + m_updateController->checkForUpdates(); + } +} + +void UpdateUiController::runInstaller() +{ + if (m_updateController) { + m_updateController->runInstaller(); + } +} diff --git a/client/ui/controllers/updateUiController.h b/client/ui/controllers/updateUiController.h new file mode 100644 index 000000000..d877443e3 --- /dev/null +++ b/client/ui/controllers/updateUiController.h @@ -0,0 +1,33 @@ +#ifndef UPDATEUICONTROLLER_H +#define UPDATEUICONTROLLER_H + +#include + +#include "core/controllers/updateController.h" + +class UpdateUiController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString changelogText READ getChangelogText NOTIFY updateFound) + Q_PROPERTY(QString headerText READ getHeaderText NOTIFY updateFound) + +public: + explicit UpdateUiController(UpdateController* updateController, QObject *parent = nullptr); + + QString getHeaderText() const; + QString getChangelogText() const; + QString getVersion() const; + +public slots: + void checkForUpdates(); + void runInstaller(); + +signals: + void updateFound(); + +private: + UpdateController* m_updateController; +}; + +#endif // UPDATEUICONTROLLER_H diff --git a/client/ui/models/newsModel.cpp b/client/ui/models/newsModel.cpp index 13bdb4ed0..ba9e209a4 100644 --- a/client/ui/models/newsModel.cpp +++ b/client/ui/models/newsModel.cpp @@ -33,8 +33,9 @@ QVariant NewsModel::data(const QModelIndex &index, int role) const case TitleRole: return item.title; case ContentRole: return item.content; case TimestampRole: return item.timestamp.toLocalTime().toString(Qt::ISODate); - case IsReadRole: return item.read; + case IsReadRole: return m_readIds.contains(item.id); case IsProcessedRole: return index.row() == m_processedIndex; + case IsUpdateRole: return item.isUpdate; default: return QVariant(); } } @@ -48,6 +49,7 @@ QHash NewsModel::roleNames() const roles[TimestampRole] = "timestamp"; roles[IsReadRole] = "read"; roles[IsProcessedRole] = "isProcessed"; + roles[IsUpdateRole] = "isUpdate"; return roles; } @@ -55,13 +57,33 @@ void NewsModel::markAsRead(int index) { if (index < 0 || index >= m_items.size()) return; - if (!m_items[index].read) { - m_items[index].read = true; - m_readIds.insert(m_items[index].id); - saveReadIds(); - QModelIndex idx = createIndex(index, 0); - emit dataChanged(idx, idx, { IsReadRole }); - emit hasUnreadChanged(); + + const QString &itemId = m_items.at(index).id; + if (itemId.isEmpty() || m_readIds.contains(itemId)) + return; + + m_readIds.insert(itemId); + saveReadIds(); + + QModelIndex idx = createIndex(index, 0); + emit dataChanged(idx, idx, { IsReadRole }); + emit hasUnreadChanged(); +} + +void NewsModel::markUpdateAsSkipped() +{ + if (!m_updateItem.has_value()) + return; + + const QString updateId = m_updateItem->id; + if (updateId.isEmpty()) + return; + + for (int i = 0; i < m_items.size(); ++i) { + if (m_items.at(i).id == updateId) { + markAsRead(i); + break; + } } } @@ -78,37 +100,65 @@ void NewsModel::setProcessedIndex(int index) emit processedIndexChanged(index); } -void NewsModel::updateModel(const QJsonArray &serverItems) +void NewsModel::setNewsList(const QJsonArray &serverItems) { - QList updatedItems; + QVector updatedItems; + updatedItems.reserve(serverItems.size()); for (const QJsonValue &value : serverItems) { if (!value.isObject()) continue; - QJsonObject object = value.toObject(); - + const QJsonObject object = value.toObject(); + NewsItem item; item.id = object.value("id").toString(); + if (item.id.isEmpty()) + continue; item.title = object.value("title").toString(); item.content = object.value("content").toString(); item.timestamp = QDateTime::fromString(object.value("timestamp").toString(), Qt::ISODate); - item.read = m_readIds.contains(object.value("id").toString()); + item.isUpdate = false; + updatedItems.append(item); } + m_apiItems = updatedItems; + updateModel(); +} + +void NewsModel::setUpdateNotification(const QString &id, const QString &title, const QString &content) +{ + if (id.isEmpty()) + return; + + NewsItem updateItem; + updateItem.id = id; + updateItem.title = title; + updateItem.content = content; + updateItem.timestamp = QDateTime::currentDateTimeUtc(); + updateItem.isUpdate = true; + + m_updateItem = updateItem; + updateModel(); +} + +void NewsModel::updateModel() +{ beginResetModel(); - m_items = updatedItems; + m_items = m_apiItems; std::sort(m_items.begin(), m_items.end(), [](const NewsItem &a, const NewsItem &b) { return a.timestamp > b.timestamp; }); + if (m_updateItem.has_value()) { + m_items.prepend(*m_updateItem); + } endResetModel(); - loadReadIds(); emit hasUnreadChanged(); } bool NewsModel::hasUnread() const { for (const NewsItem &item : m_items) { - if (!item.read) + if (!m_readIds.contains(item.id)) return true; } return false; diff --git a/client/ui/models/newsModel.h b/client/ui/models/newsModel.h index c2d4e488c..fa9ba28b1 100644 --- a/client/ui/models/newsModel.h +++ b/client/ui/models/newsModel.h @@ -7,6 +7,7 @@ #include #include #include +#include struct NewsItem { @@ -14,7 +15,7 @@ struct NewsItem QString title; QString content; QDateTime timestamp; - bool read; + bool isUpdate = false; }; class NewsModel : public QAbstractListModel @@ -27,17 +28,20 @@ public: ContentRole, TimestampRole, IsReadRole, - IsProcessedRole + IsProcessedRole, + IsUpdateRole }; explicit NewsModel(class SecureAppSettingsRepository* appSettingsRepository, QObject *parent = nullptr); Q_INVOKABLE void markAsRead(int index); + Q_INVOKABLE void markUpdateAsSkipped(); Q_PROPERTY(int processedIndex READ processedIndex WRITE setProcessedIndex NOTIFY processedIndexChanged) Q_PROPERTY(bool hasUnread READ hasUnread NOTIFY hasUnreadChanged) int processedIndex() const; void setProcessedIndex(int index); - void updateModel(const QJsonArray &items); + void setNewsList(const QJsonArray &items); + void setUpdateNotification(const QString &id, const QString &title, const QString &content); bool hasUnread() const; int rowCount(const QModelIndex &parent = QModelIndex()) const override; @@ -50,11 +54,14 @@ signals: private: QVector m_items; + QVector m_apiItems; + std::optional m_updateItem; int m_processedIndex = -1; class SecureAppSettingsRepository* m_appSettingsRepository; QSet m_readIds; void loadReadIds(); void saveReadIds() const; + void updateModel(); }; #endif // NEWSMODEL_H diff --git a/client/ui/qml/Components/ChangelogDrawer.qml b/client/ui/qml/Components/ChangelogDrawer.qml new file mode 100644 index 000000000..1bb767be4 --- /dev/null +++ b/client/ui/qml/Components/ChangelogDrawer.qml @@ -0,0 +1,103 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import "../Controls2" +import "../Controls2/TextTypes" + +import "../Config" + +DrawerType2 { + id: root + + anchors.fill: parent + expandedHeight: parent.height * 0.9 + + expandedStateContent: Item { + implicitHeight: root.expandedHeight + + Header2TextType { + id: header + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 16 + anchors.rightMargin: 16 + anchors.leftMargin: 16 + anchors.bottomMargin: 16 + + text: UpdateController.headerText + } + + FlickableType { + anchors.top: header.bottom + anchors.bottom: updateButton.top + contentHeight: changelog.height + 32 + + ParagraphTextType { + id: changelog + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 16 + anchors.rightMargin: 16 + anchors.leftMargin: 16 + anchors.bottomMargin: 16 + + HoverHandler { + enabled: parent.hoveredLink + cursorShape: Qt.PointingHandCursor + } + + onLinkActivated: function(link) { + Qt.openUrlExternally(link) + } + + text: UpdateController.changelogText + textFormat: Text.MarkdownText + } + } + + BasicButtonType { + id: updateButton + anchors.bottom: skipButton.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottomMargin: 8 + anchors.rightMargin: 16 + anchors.leftMargin: 16 + + text: qsTr("Update") + + clickedFunc: function() { + PageController.showBusyIndicator(true) + UpdateController.runInstaller() + PageController.showBusyIndicator(false) + root.closeTriggered() + } + } + + BasicButtonType { + id: skipButton + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottomMargin: 16 + anchors.rightMargin: 16 + anchors.leftMargin: 16 + + defaultColor: "transparent" + hoveredColor: Qt.rgba(1, 1, 1, 0.08) + pressedColor: Qt.rgba(1, 1, 1, 0.12) + disabledColor: "#878B91" + textColor: "#D7D8DB" + borderWidth: 1 + + text: qsTr("Skip") + + clickedFunc: function() { + root.closeTriggered() + } + } + } +} diff --git a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml index d9b6b29c8..24fe66087 100644 --- a/client/ui/qml/Components/SubscriptionExpiredDrawer.qml +++ b/client/ui/qml/Components/SubscriptionExpiredDrawer.qml @@ -15,7 +15,7 @@ DrawerType2 { property bool isRenewalAvailable: false onOpened: { - isRenewalAvailable = ServersModel.getProcessedServerData("isRenewalAvailable") && !ApiAccountInfoModel.data("isInAppPurchase") + isRenewalAvailable = ServersModel.getDefaultServerData("isRenewalAvailable") && !ApiAccountInfoModel.data("isInAppPurchase") } expandedStateContent: ColumnLayout { @@ -28,7 +28,7 @@ DrawerType2 { spacing: 0 onImplicitHeightChanged: { - root.expandedHeight = content.implicitHeight + 32 + SettingsController.safeAreaBottomMargin + root.expandedHeight = content.implicitHeight + 32 + PageController.safeAreaBottomMargin } Item { @@ -43,7 +43,7 @@ DrawerType2 { anchors.left: parent.left anchors.right: parent.right - text: ServersModel.getProcessedServerData("name") + qsTr(" subscription has expired") + text: ServersModel.getDefaultServerData("name") + qsTr(" subscription has expired") horizontalAlignment: Text.AlignLeft } } @@ -76,7 +76,7 @@ DrawerType2 { textColor: AmneziaStyle.color.midnightBlack clickedFunc: function() { - SubscriptionUiController.getRenewalLink(ServersUiController.getProcessedServerIndex()) + SubscriptionUiController.getRenewalLink(ServersUiController.defaultIndex) } } @@ -96,7 +96,7 @@ DrawerType2 { clickedFunc: function() { PageController.showBusyIndicator(true) - let result = ApiSettingsController.getAccountInfo(false) + let result = SubscriptionUiController.getAccountInfo(ServersUiController.defaultIndex, false) PageController.showBusyIndicator(false) if (result) { root.closeTriggered() diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 143352fb3..7eb119244 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -469,7 +469,7 @@ PageType { PageController.showNotificationMessage(qsTr("Cannot unlink device during active connection")) } else { PageController.showBusyIndicator(true) - if (SubscriptionUiController.deactivateDevice(ServersUiController.getProcessedServerIndex(), false)) { + if (SubscriptionUiController.deactivateDevice(ServersUiController.getProcessedServerIndex())) { SubscriptionUiController.getAccountInfo(ServersUiController.getProcessedServerIndex(), true) } PageController.showBusyIndicator(false) diff --git a/client/ui/qml/Pages2/PageSettingsNewsDetail.qml b/client/ui/qml/Pages2/PageSettingsNewsDetail.qml index c67b4303f..b304ab5a2 100644 --- a/client/ui/qml/Pages2/PageSettingsNewsDetail.qml +++ b/client/ui/qml/Pages2/PageSettingsNewsDetail.qml @@ -14,6 +14,7 @@ import SortFilterProxyModel 0.2 PageType { id: root property var newsItem + property bool isUpdateItem: newsItem && (newsItem.isUpdate !== undefined ? newsItem.isUpdate : false) SortFilterProxyModel { id: proxyNews @@ -54,7 +55,7 @@ PageType { Layout.fillWidth: true Layout.leftMargin: 16 Layout.rightMargin: 16 - headerText: newsItem.title + headerText: newsItem ? newsItem.title : "" } ParagraphTextType { @@ -62,9 +63,9 @@ PageType { Layout.topMargin: 16 Layout.leftMargin: 16 Layout.rightMargin: 16 - text: newsItem.content + text: newsItem ? newsItem.content : "" - textFormat: Text.RichText + textFormat: root.isUpdateItem ? Text.MarkdownText : Text.RichText onLinkActivated: function(link) { Qt.openUrlExternally(link) @@ -76,6 +77,47 @@ PageType { cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : Qt.ArrowCursor } } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + visible: root.isUpdateItem + text: qsTr("Update") + + clickedFunc: function() { + if (!root.isUpdateItem) + return + PageController.showBusyIndicator(true) + UpdateController.runInstaller() + PageController.showBusyIndicator(false) + PageController.closePage() + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + Layout.bottomMargin: 16 + visible: root.isUpdateItem + defaultColor: "transparent" + hoveredColor: Qt.rgba(1, 1, 1, 0.08) + pressedColor: Qt.rgba(1, 1, 1, 0.12) + disabledColor: "#878B91" + textColor: "#D7D8DB" + borderWidth: 1 + text: qsTr("Skip") + + clickedFunc: function() { + if (!root.isUpdateItem) + return + NewsModel.markUpdateAsSkipped() + PageController.closePage() + } + } } } } diff --git a/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml b/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml index 2aa4ba008..7041d4089 100644 --- a/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml +++ b/client/ui/qml/Pages2/PageSettingsNewsNotifications.qml @@ -69,8 +69,10 @@ PageType { rightImageSource: "qrc:/images/controls/chevron-right.svg" clickedFunction: function() { + if (!isUpdate) { NewsModel.markAsRead(index) - NewsModel.processedIndex = index + } + NewsModel.processedIndex = index PageController.goToPage(PageEnum.PageSettingsNewsDetail) } } diff --git a/client/ui/qml/main2.qml b/client/ui/qml/main2.qml index 0ad23f929..2a7730482 100644 --- a/client/ui/qml/main2.qml +++ b/client/ui/qml/main2.qml @@ -144,6 +144,10 @@ Window { busyIndicator.visible = visible PageController.disableControls(visible) } + + function onShowChangelogDrawer() { + changelogDrawer.openTriggered() + } } Connections { @@ -386,4 +390,14 @@ Window { onAccepted: SystemController.fileDialogClosed(true) onRejected: SystemController.fileDialogClosed(false) } + + Item { + anchors.fill: parent + + ChangelogDrawer { + id: changelogDrawer + + anchors.fill: parent + } + } } diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index 18957481b..6a004038a 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -7,6 +7,7 @@ Components/HomeContainersListView.qml Components/HomeSplitTunnelingDrawer.qml Components/InstalledAppsDrawer.qml + Components/ChangelogDrawer.qml Components/QuestionDrawer.qml Components/SelectLanguageDrawer.qml Components/ServersListView.qml diff --git a/deploy/deploy_s3.sh b/deploy/deploy_s3.sh index 193d26079..e2df5f3f1 100755 --- a/deploy/deploy_s3.sh +++ b/deploy/deploy_s3.sh @@ -12,7 +12,8 @@ mkdir -p dist cd dist -echo $VERSION >> VERSION +echo $VERSION > VERSION +curl -s https://api.github.com/repos/amnezia-vpn/amnezia-client/releases/tags/$VERSION | jq -r .published_at > RELEASE_DATE curl -s https://api.github.com/repos/amnezia-vpn/amnezia-client/releases/tags/$VERSION | jq -r .body | tr -d '\r' > CHANGELOG curl -s https://api.github.com/repos/amnezia-vpn/amnezia-client/releases/tags/$VERSION | jq -r .published_at > RELEASE_DATE