diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 2f138157d..21c86363d 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -34,6 +34,8 @@ add_definitions(-DDEV_S3_ENDPOINT="$ENV{DEV_S3_ENDPOINT}") add_definitions(-DFREE_V2_ENDPOINT="$ENV{FREE_V2_ENDPOINT}") add_definitions(-DPREM_V1_ENDPOINT="$ENV{PREM_V1_ENDPOINT}") +include(../.cache/agw_rsa_public_keys.cmake) + if(WIN32 OR (APPLE AND NOT IOS) OR (LINUX AND NOT ANDROID)) set(PACKAGES ${PACKAGES} Widgets) endif() diff --git a/client/cmake/sources.cmake b/client/cmake/sources.cmake index 497757757..3ae725d63 100644 --- a/client/cmake/sources.cmake +++ b/client/cmake/sources.cmake @@ -44,6 +44,7 @@ set(HEADERS ${HEADERS} ${CLIENT_ROOT_DIR}/core/controllers/settingsController.h ${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.h ${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.h + ${CLIENT_ROOT_DIR}/core/controllers/api/pairingController.h ${CLIENT_ROOT_DIR}/core/controllers/api/newsController.h ${CLIENT_ROOT_DIR}/core/controllers/updateController.h ${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.h @@ -119,6 +120,7 @@ set(SOURCES ${SOURCES} ${CLIENT_ROOT_DIR}/core/controllers/settingsController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/servicesCatalogController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/subscriptionController.cpp + ${CLIENT_ROOT_DIR}/core/controllers/api/pairingController.cpp ${CLIENT_ROOT_DIR}/core/controllers/api/newsController.cpp ${CLIENT_ROOT_DIR}/core/controllers/updateController.cpp ${CLIENT_ROOT_DIR}/core/repositories/secureServersRepository.cpp diff --git a/client/core/controllers/api/pairingController.cpp b/client/core/controllers/api/pairingController.cpp new file mode 100644 index 000000000..1c56acad5 --- /dev/null +++ b/client/core/controllers/api/pairingController.cpp @@ -0,0 +1,203 @@ +#include "pairingController.h" + +#include +#include + +#include "core/controllers/gatewayController.h" +#include "core/repositories/secureAppSettingsRepository.h" +#include "core/utils/api/apiUtils.h" +#include "core/utils/constants/apiConstants.h" +#include "core/utils/constants/apiKeys.h" +#include "version.h" + +using namespace amnezia; + +namespace +{ +constexpr auto kGenerateQrEndpoint = "%1api/v1/generate_qr"; +constexpr auto kScanQrEndpoint = "%1api/v1/scan_qr"; + +bool isLocalGatewayHost(const QString &gatewayUrl) +{ + return gatewayUrl.contains(QStringLiteral("127.0.0.1"), Qt::CaseInsensitive) + || gatewayUrl.contains(QStringLiteral("localhost"), Qt::CaseInsensitive); +} + +ErrorCode applyGatewayOrOpenApiGenerateError(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload) +{ + ErrorCode apiStatus = apiUtils::errorCodeFromGatewayJsonHttpStatus(obj); + if (apiStatus != ErrorCode::NoError) { + return apiStatus; + } + + const QString config = obj.value(apiDefs::key::config).toString(); + if (!config.isEmpty()) { + outPayload.config = config; + outPayload.serviceInfo = obj.value(apiDefs::key::serviceInfo).toObject(); + outPayload.supportedProtocols = obj.value(apiDefs::key::supportedProtocols).toArray(); + return ErrorCode::NoError; + } + + if (obj.contains(QStringLiteral("detail"))) { + return ErrorCode::ApiConfigEmptyError; + } + + const QString msg = obj.value(QStringLiteral("message")).toString(); + if (msg.contains(QStringLiteral("timeout"), Qt::CaseInsensitive)) { + return ErrorCode::ApiConfigTimeoutError; + } + if (msg.contains(QStringLiteral("Too Many"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingRateLimitedError; + } + if (msg.contains(QStringLiteral("Unavailable"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingServiceUnavailableError; + } + if (!msg.isEmpty()) { + return ErrorCode::ApiConfigDownloadError; + } + + return ErrorCode::ApiConfigEmptyError; +} + +ErrorCode applyGatewayOrOpenApiScanError(const QJsonObject &obj) +{ + ErrorCode apiStatus = apiUtils::errorCodeFromGatewayJsonHttpStatus(obj); + if (apiStatus != ErrorCode::NoError) { + return apiStatus; + } + + if (obj.value(QStringLiteral("message")).toString() == QLatin1String("OK")) { + return ErrorCode::NoError; + } + + if (obj.contains(QStringLiteral("detail"))) { + return ErrorCode::ApiPairingForbiddenError; + } + + const QString msg = obj.value(QStringLiteral("message")).toString(); + if (msg.contains(QStringLiteral("not found"), Qt::CaseInsensitive) || msg.contains(QStringLiteral("expired"), Qt::CaseInsensitive)) { + return ErrorCode::ApiNotFoundError; + } + if (msg.contains(QStringLiteral("Conflict"), Qt::CaseInsensitive) || msg.contains(QStringLiteral("already"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingConflictError; + } + if (msg.contains(QStringLiteral("Too Many"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingRateLimitedError; + } + if (msg.contains(QStringLiteral("Unavailable"), Qt::CaseInsensitive)) { + return ErrorCode::ApiPairingServiceUnavailableError; + } + if (!msg.isEmpty()) { + return ErrorCode::ApiConfigDownloadError; + } + + return ErrorCode::ApiConfigEmptyError; +} + +ErrorCode interpretGenerateQrJson(const QJsonObject &obj, PairingController::QrPairingConfigPayload &outPayload) +{ + return applyGatewayOrOpenApiGenerateError(obj, outPayload); +} + +ErrorCode interpretScanQrJson(const QJsonObject &obj) +{ + return applyGatewayOrOpenApiScanError(obj); +} +} // namespace + +ErrorCode PairingController::parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload) +{ + outPayload = QrPairingConfigPayload {}; + const QJsonObject obj = QJsonDocument::fromJson(responseBody).object(); + return interpretGenerateQrJson(obj, outPayload); +} + +ErrorCode PairingController::parseScanQrResponseBody(const QByteArray &responseBody) +{ + const QJsonObject obj = QJsonDocument::fromJson(responseBody).object(); + return interpretScanQrJson(obj); +} + +PairingController::PairingController(SecureAppSettingsRepository *appSettingsRepository) + : m_appSettingsRepository(appSettingsRepository) +{ +} + +int PairingController::pairingLongPollTimeoutMsecs() const +{ + const QString endpoint = m_appSettingsRepository->getGatewayEndpoint(); + if (isLocalGatewayHost(endpoint)) { + return 120 * 1000; + } + return 30 * 1000; +} + +QJsonObject PairingController::buildGenerateQrPayload(const QString &qrUuid) const +{ + QJsonObject o; + o[apiDefs::key::qrUuid] = qrUuid; + o[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true); + o[apiDefs::key::appVersion] = QString(APP_VERSION); + o[apiDefs::key::osVersion] = QSysInfo::productType(); + return o; +} + +QJsonObject PairingController::buildScanQrPayload(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey) const +{ + QJsonObject auth; + auth[apiDefs::key::apiKey] = apiKey; + + QJsonObject o; + o[apiDefs::key::qrUuid] = qrUuid; + o[apiDefs::key::config] = vpnConfig; + o[apiDefs::key::serviceInfo] = serviceInfo; + o[apiDefs::key::supportedProtocols] = supportedProtocols; + o[apiDefs::key::authData] = auth; + o[apiDefs::key::installationUuid] = m_appSettingsRepository->getInstallationUuid(true); + o[apiDefs::key::appVersion] = QString(APP_VERSION); + o[apiDefs::key::osVersion] = QSysInfo::productType(); + return o; +} + +ErrorCode PairingController::startPairing(const QString &qrUuid, QrPairingConfigPayload &outPayload) +{ + outPayload = QrPairingConfigPayload {}; + if (qrUuid.isEmpty()) { + return ErrorCode::ApiConfigEmptyError; + } + + GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(), m_appSettingsRepository->isDevGatewayEnv(), + pairingLongPollTimeoutMsecs(), m_appSettingsRepository->isStrictKillSwitchEnabled()); + + QByteArray responseBody; + const ErrorCode transportError = gatewayController.post(QString::fromLatin1(kGenerateQrEndpoint), buildGenerateQrPayload(qrUuid), responseBody); + if (transportError != ErrorCode::NoError) { + return transportError; + } + + const QJsonObject obj = QJsonDocument::fromJson(responseBody).object(); + return interpretGenerateQrJson(obj, outPayload); +} + +ErrorCode PairingController::completePairing(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey) +{ + if (qrUuid.isEmpty() || vpnConfig.isEmpty() || apiKey.isEmpty()) { + return ErrorCode::ApiConfigEmptyError; + } + + GatewayController gatewayController(m_appSettingsRepository->getGatewayEndpoint(), m_appSettingsRepository->isDevGatewayEnv(), + apiDefs::requestTimeoutMsecs, m_appSettingsRepository->isStrictKillSwitchEnabled()); + + QByteArray responseBody; + const ErrorCode transportError = + gatewayController.post(QString::fromLatin1(kScanQrEndpoint), + buildScanQrPayload(qrUuid, vpnConfig, serviceInfo, supportedProtocols, apiKey), responseBody); + if (transportError != ErrorCode::NoError) { + return transportError; + } + + const QJsonObject obj = QJsonDocument::fromJson(responseBody).object(); + return interpretScanQrJson(obj); +} diff --git a/client/core/controllers/api/pairingController.h b/client/core/controllers/api/pairingController.h new file mode 100644 index 000000000..b1a7f26e2 --- /dev/null +++ b/client/core/controllers/api/pairingController.h @@ -0,0 +1,45 @@ +#ifndef PAIRINGCONTROLLER_H +#define PAIRINGCONTROLLER_H + +#include +#include +#include + +#include "core/utils/errorCodes.h" + +class SecureAppSettingsRepository; + +/** + * Core API for QR pairing against Amnezia gateway (POST /api/v1/generate_qr, /api/v1/scan_qr). + * Phase 1: transport via GatewayController, error mapping incl. gateway `http_status` wrapper and OpenAPI-style bodies. + */ +class PairingController +{ +public: + struct QrPairingConfigPayload + { + QString config; + QJsonObject serviceInfo; + QJsonArray supportedProtocols; + }; + + explicit PairingController(SecureAppSettingsRepository *appSettingsRepository); + + int pairingLongPollTimeoutMsecs() const; + + QJsonObject buildGenerateQrPayload(const QString &qrUuid) const; + QJsonObject buildScanQrPayload(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey) const; + + static amnezia::ErrorCode parseGenerateQrResponseBody(const QByteArray &responseBody, QrPairingConfigPayload &outPayload); + static amnezia::ErrorCode parseScanQrResponseBody(const QByteArray &responseBody); + + amnezia::ErrorCode startPairing(const QString &qrUuid, QrPairingConfigPayload &outPayload); + amnezia::ErrorCode completePairing(const QString &qrUuid, const QString &vpnConfig, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey); + +private: + SecureAppSettingsRepository *m_appSettingsRepository; +}; + +#endif // PAIRINGCONTROLLER_H diff --git a/client/core/controllers/coreController.cpp b/client/core/controllers/coreController.cpp index 227850b6d..31917420b 100644 --- a/client/core/controllers/coreController.cpp +++ b/client/core/controllers/coreController.cpp @@ -145,6 +145,7 @@ void CoreController::initCoreControllers() m_allowedDnsController = new AllowedDnsController(m_appSettingsRepository); m_servicesCatalogController = new ServicesCatalogController(m_appSettingsRepository); m_subscriptionController = new SubscriptionController(m_serversRepository, m_appSettingsRepository); + m_pairingController = new PairingController(m_appSettingsRepository); m_newsController = new NewsController(m_appSettingsRepository, m_serversController); m_updateController = new UpdateController(m_appSettingsRepository, this); @@ -211,6 +212,9 @@ void CoreController::initControllers() m_apiCountryModel, m_apiDevicesModel, m_settingsController, this); setQmlContextProperty("SubscriptionUiController", m_subscriptionUiController); + m_pairingUiController = new PairingUiController(m_pairingController, m_serversController, m_subscriptionController, m_appSettingsRepository, this); + setQmlContextProperty("PairingUiController", m_pairingUiController); + m_apiNewsUiController = new ApiNewsUiController(m_newsModel, m_newsController, this); setQmlContextProperty("ApiNewsController", m_apiNewsUiController); diff --git a/client/core/controllers/coreController.h b/client/core/controllers/coreController.h index 0d77c5167..b9f95c6b6 100644 --- a/client/core/controllers/coreController.h +++ b/client/core/controllers/coreController.h @@ -10,6 +10,8 @@ #endif #include "ui/controllers/api/subscriptionUiController.h" +#include "ui/controllers/api/pairingUiController.h" +#include "core/controllers/api/pairingController.h" #include "ui/controllers/api/apiNewsUiController.h" #include "ui/controllers/appSplitTunnelingUiController.h" #include "ui/controllers/allowedDnsUiController.h" @@ -164,6 +166,7 @@ private: UpdateUiController* m_updateUiController; SubscriptionUiController* m_subscriptionUiController; + PairingUiController* m_pairingUiController; ApiNewsUiController* m_apiNewsUiController; ServicesCatalogUiController* m_servicesCatalogUiController; @@ -175,6 +178,7 @@ private: AllowedDnsController* m_allowedDnsController; ServicesCatalogController* m_servicesCatalogController; SubscriptionController* m_subscriptionController; + PairingController* m_pairingController; NewsController* m_newsController; UpdateController* m_updateController; InstallController* m_installController; diff --git a/client/core/controllers/coreSignalHandlers.cpp b/client/core/controllers/coreSignalHandlers.cpp index 449a8af1d..ec1fc64a0 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/api/pairingUiController.h" #include "ui/controllers/updateUiController.h" #include "ui/models/serversModel.h" #include "core/controllers/serversController.h" @@ -97,6 +98,9 @@ void CoreSignalHandlers::initErrorMessagesHandler() connect(m_coreController->m_subscriptionUiController, &SubscriptionUiController::errorOccurred, m_coreController->m_pageController, qOverload(&PageController::showErrorMessage)); + connect(m_coreController->m_pairingUiController, &PairingUiController::errorOccurred, m_coreController->m_pageController, + qOverload(&PageController::showErrorMessage)); + connect(m_coreController->m_settingsUiController, &SettingsUiController::errorOccurred, m_coreController->m_pageController, qOverload(&PageController::showErrorMessage)); } diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index 23ced44f3..562708b06 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -15,6 +15,8 @@ #include "QBlockCipher.h" #include "QRsa.h" +#include "embedded_agw_public_keys.h" + #include "amneziaApplication.h" #include "core/utils/api/apiUtils.h" #include "core/utils/constants/apiKeys.h" diff --git a/client/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index eca4689fb..ec017f64e 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -132,6 +132,36 @@ apiDefs::ConfigSource apiUtils::getConfigSource(const QJsonObject &serverConfigO return static_cast(serverConfigObject.value(configKey::configVersion).toInt()); } +amnezia::ErrorCode apiUtils::errorCodeFromGatewayJsonHttpStatus(const QJsonObject &jsonObj) +{ + if (!jsonObj.contains(QStringLiteral("http_status"))) { + return amnezia::ErrorCode::NoError; + } + const int st = jsonObj.value(QStringLiteral("http_status")).toInt(-1); + switch (st) { + case 200: + return amnezia::ErrorCode::NoError; + case 400: + return amnezia::ErrorCode::ApiConfigEmptyError; + case 403: + return amnezia::ErrorCode::ApiPairingForbiddenError; + case 404: + return amnezia::ErrorCode::ApiNotFoundError; + case 408: + return amnezia::ErrorCode::ApiConfigTimeoutError; + case 409: + return amnezia::ErrorCode::ApiPairingConflictError; + case 429: + return amnezia::ErrorCode::ApiPairingRateLimitedError; + case 500: + return amnezia::ErrorCode::ApiConfigDownloadError; + case 503: + return amnezia::ErrorCode::ApiPairingServiceUnavailableError; + default: + return amnezia::ErrorCode::ApiConfigDownloadError; + } +} + amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &sslErrors, const QString &replyErrorString, const QNetworkReply::NetworkError &replyError, const int httpStatusCode, const QByteArray &responseBody) diff --git a/client/core/utils/api/apiUtils.h b/client/core/utils/api/apiUtils.h index be770defa..6c6344083 100644 --- a/client/core/utils/api/apiUtils.h +++ b/client/core/utils/api/apiUtils.h @@ -1,6 +1,7 @@ #ifndef APIUTILS_H #define APIUTILS_H +#include #include #include @@ -28,6 +29,9 @@ namespace apiUtils const QNetworkReply::NetworkError &replyError, const int httpStatusCode, const QByteArray &responseBody); + /** Maps gateway JSON `http_status` field (when present) to ErrorCode. Returns NoError if field is missing. */ + amnezia::ErrorCode errorCodeFromGatewayJsonHttpStatus(const QJsonObject &jsonObj); + QString getPremiumV1VpnKey(const QJsonObject &serverConfigObject); QString getPremiumV2VpnKey(const QJsonObject &serverConfigObject); } diff --git a/client/core/utils/constants/apiKeys.h b/client/core/utils/constants/apiKeys.h index 2e037bca6..2402fa6b3 100644 --- a/client/core/utils/constants/apiKeys.h +++ b/client/core/utils/constants/apiKeys.h @@ -23,6 +23,7 @@ namespace apiDefs constexpr QLatin1String availableCountries("available_countries"); constexpr QLatin1String installationUuid("installation_uuid"); constexpr QLatin1String uuid("installation_uuid"); + constexpr QLatin1String qrUuid("qr_uuid"); constexpr QLatin1String osVersion("os_version"); constexpr QLatin1String userCountryCode("user_country_code"); constexpr QLatin1String serverCountryCode("server_country_code"); diff --git a/client/core/utils/errorCodes.h b/client/core/utils/errorCodes.h index 00e8c6b20..c5ef7b7c2 100644 --- a/client/core/utils/errorCodes.h +++ b/client/core/utils/errorCodes.h @@ -98,6 +98,12 @@ namespace amnezia ApiNoPurchasedSubscriptionsError = 1115, ApiTrialAlreadyUsedError = 1116, + // QR pairing (gateway /api/v1/generate_qr, /api/v1/scan_qr) + ApiPairingForbiddenError = 1117, + ApiPairingConflictError = 1118, + ApiPairingRateLimitedError = 1119, + ApiPairingServiceUnavailableError = 1120, + // QFile errors OpenError = 1200, ReadError = 1201, diff --git a/client/core/utils/errorStrings.cpp b/client/core/utils/errorStrings.cpp index 591716ec9..5a564bbe3 100644 --- a/client/core/utils/errorStrings.cpp +++ b/client/core/utils/errorStrings.cpp @@ -83,6 +83,10 @@ QString errorString(ErrorCode code) { case (ErrorCode::ApiSubscriptionNotActiveError): errorMessage = QObject::tr("No active subscription found"); break; case (ErrorCode::ApiNoPurchasedSubscriptionsError): errorMessage = QObject::tr("No purchased subscriptions found. Please purchase a subscription first"); break; case (ErrorCode::ApiTrialAlreadyUsedError): errorMessage = QObject::tr("This email address has already been used to activate a trial"); break; + case (ErrorCode::ApiPairingForbiddenError): errorMessage = QObject::tr("QR pairing was rejected (forbidden)"); break; + case (ErrorCode::ApiPairingConflictError): errorMessage = QObject::tr("This QR code has already been used"); break; + case (ErrorCode::ApiPairingRateLimitedError): errorMessage = QObject::tr("Too many requests. Please try again later"); break; + case (ErrorCode::ApiPairingServiceUnavailableError): errorMessage = QObject::tr("Service temporarily unavailable. Please try again later"); break; // QFile errors case(ErrorCode::OpenError): errorMessage = QObject::tr("QFile error: The file could not be opened"); break; diff --git a/client/ui/controllers/api/pairingUiController.cpp b/client/ui/controllers/api/pairingUiController.cpp new file mode 100644 index 000000000..cfbcb59e0 --- /dev/null +++ b/client/ui/controllers/api/pairingUiController.cpp @@ -0,0 +1,279 @@ +#include "pairingUiController.h" + +#include + +#include "core/controllers/gatewayController.h" +#include "core/models/serverConfig.h" +#include "core/models/api/apiV2ServerConfig.h" +#include "core/utils/constants/apiConstants.h" +#include "core/utils/qrCodeUtils.h" + +using namespace amnezia; + +namespace +{ +constexpr auto kGenerateQrPath = "%1api/v1/generate_qr"; +constexpr auto kScanQrPath = "%1api/v1/scan_qr"; +} + +PairingUiController::PairingUiController(PairingController *pairingController, ServersController *serversController, + SubscriptionController *subscriptionController, + SecureAppSettingsRepository *appSettingsRepository, QObject *parent) + : QObject(parent), + m_pairingController(pairingController), + m_serversController(serversController), + m_subscriptionController(subscriptionController), + m_appSettingsRepository(appSettingsRepository) +{ +} + +QVariantList PairingUiController::tvQrCodes() const +{ + QVariantList list; + list.reserve(m_tvQrCodes.size()); + for (const QString &s : m_tvQrCodes) { + list.append(s); + } + return list; +} + +int PairingUiController::tvQrCodesCount() const +{ + return m_tvQrCodes.size(); +} + +QString PairingUiController::tvSessionUuid() const +{ + return m_tvSessionUuid; +} + +bool PairingUiController::tvPairingBusy() const +{ + return m_tvPairingBusy; +} + +QString PairingUiController::tvStatusMessage() const +{ + return m_tvStatusMessage; +} + +bool PairingUiController::phonePairingBusy() const +{ + return m_phonePairingBusy; +} + +QString PairingUiController::phoneStatusMessage() const +{ + return m_phoneStatusMessage; +} + +void PairingUiController::setTvBusy(bool busy) +{ + if (m_tvPairingBusy == busy) { + return; + } + m_tvPairingBusy = busy; + emit tvPairingBusyChanged(); +} + +void PairingUiController::setPhoneBusy(bool busy) +{ + if (m_phonePairingBusy == busy) { + return; + } + m_phonePairingBusy = busy; + emit phonePairingBusyChanged(); +} + +void PairingUiController::resetTvQrDisplay() +{ + m_tvQrCodes.clear(); + m_tvSessionUuid.clear(); + emit tvQrCodesChanged(); + emit tvSessionUuidChanged(); +} + +void PairingUiController::startTvQrSession() +{ + if (!m_pairingController || !m_appSettingsRepository) { + return; + } + if (m_tvPairingBusy) { + return; + } + + if (m_tvWatcher) { + m_tvWatcher->disconnect(); + m_tvWatcher->deleteLater(); + m_tvWatcher.clear(); + } + + m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces); + const QByteArray qrPayload = m_tvSessionUuid.toUtf8(); + m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeries(qrPayload); + emit tvQrCodesChanged(); + emit tvSessionUuidChanged(); + + m_tvStatusMessage = tr("Waiting for premium device to confirm…"); + emit tvStatusMessageChanged(); + + setTvBusy(true); + + const bool isTestPurchase = false; + auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), + m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), + m_pairingController->pairingLongPollTimeoutMsecs(), + m_appSettingsRepository->isStrictKillSwitchEnabled()); + + const QJsonObject payload = m_pairingController->buildGenerateQrPayload(m_tvSessionUuid); + const QFuture> future = gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload); + + auto *watcher = new QFutureWatcher>(this); + m_tvWatcher = watcher; + QObject::connect(watcher, &QFutureWatcher>::finished, this, + [this, gatewayController, watcher]() { + Q_UNUSED(gatewayController); + const auto result = watcher->result(); + watcher->deleteLater(); + if (m_tvWatcher == watcher) { + m_tvWatcher.clear(); + } + + setTvBusy(false); + + if (result.first != ErrorCode::NoError) { + m_tvStatusMessage = tr("Pairing failed"); + emit tvStatusMessageChanged(); + emit errorOccurred(result.first); + return; + } + + PairingController::QrPairingConfigPayload out; + const ErrorCode parseErr = PairingController::parseGenerateQrResponseBody(result.second, out); + if (parseErr != ErrorCode::NoError) { + m_tvStatusMessage = tr("Pairing failed"); + emit tvStatusMessageChanged(); + emit errorOccurred(parseErr); + return; + } + + m_tvStatusMessage = tr("Configuration received"); + emit tvStatusMessageChanged(); + emit tvPairingConfigReceived(); + }); + watcher->setFuture(future); +} + +void PairingUiController::cancelTvQrSession() +{ + if (m_tvWatcher) { + m_tvWatcher->disconnect(); + m_tvWatcher->deleteLater(); + m_tvWatcher.clear(); + } + setTvBusy(false); + m_tvStatusMessage.clear(); + emit tvStatusMessageChanged(); + resetTvQrDisplay(); +} + +void PairingUiController::submitPhonePairing(const QString &qrUuid, int serverIndex) +{ + if (!m_pairingController || !m_serversController || !m_subscriptionController || !m_appSettingsRepository) { + return; + } + if (m_phonePairingBusy) { + return; + } + + const QString trimmedUuid = qrUuid.trimmed(); + if (trimmedUuid.isEmpty()) { + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return; + } + + if (serverIndex < 0 || serverIndex >= m_serversController->getServersCount()) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + const ServerConfig serverConfig = m_serversController->getServerConfig(serverIndex); + if (!serverConfig.isApiV2()) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + const ApiV2ServerConfig *apiV2 = serverConfig.as(); + if (!apiV2) { + emit errorOccurred(ErrorCode::InternalError); + return; + } + + QString vpnKey; + const ErrorCode keyErr = m_subscriptionController->prepareVpnKeyExport(serverIndex, vpnKey); + if (keyErr != ErrorCode::NoError) { + emit errorOccurred(keyErr); + return; + } + + const QJsonObject serviceInfo = apiV2->apiConfig.serviceInfo.toJson(); + const QJsonArray supportedProtocols = apiV2->apiConfig.supportedProtocols; + const QString apiKey = apiV2->authData.apiKey; + if (apiKey.isEmpty()) { + emit errorOccurred(ErrorCode::ApiConfigEmptyError); + return; + } + + m_phoneStatusMessage = tr("Sending…"); + emit phoneStatusMessageChanged(); + setPhoneBusy(true); + + runPhonePairingRequest(trimmedUuid, apiV2->apiConfig.isTestPurchase, vpnKey, serviceInfo, supportedProtocols, apiKey); +} + +void PairingUiController::runPhonePairingRequest(const QString &qrUuid, const bool isTestPurchase, const QString &vpnKey, + const QJsonObject &serviceInfo, const QJsonArray &supportedProtocols, + const QString &apiKey) +{ + auto gatewayController = QSharedPointer::create(m_appSettingsRepository->getGatewayEndpoint(isTestPurchase), + m_appSettingsRepository->isDevGatewayEnv(isTestPurchase), + apiDefs::requestTimeoutMsecs, + m_appSettingsRepository->isStrictKillSwitchEnabled()); + + const QJsonObject payload = m_pairingController->buildScanQrPayload(qrUuid, vpnKey, serviceInfo, supportedProtocols, apiKey); + const QFuture> future = gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload); + + auto *watcher = new QFutureWatcher>(this); + m_phoneWatcher = watcher; + QObject::connect(watcher, &QFutureWatcher>::finished, this, + [this, gatewayController, watcher]() { + Q_UNUSED(gatewayController); + const auto result = watcher->result(); + watcher->deleteLater(); + if (m_phoneWatcher == watcher) { + m_phoneWatcher.clear(); + } + + setPhoneBusy(false); + + if (result.first != ErrorCode::NoError) { + m_phoneStatusMessage = tr("Send failed"); + emit phoneStatusMessageChanged(); + emit errorOccurred(result.first); + return; + } + + const ErrorCode parseErr = PairingController::parseScanQrResponseBody(result.second); + if (parseErr != ErrorCode::NoError) { + m_phoneStatusMessage = tr("Send failed"); + emit phoneStatusMessageChanged(); + emit errorOccurred(parseErr); + return; + } + + m_phoneStatusMessage = tr("Sent successfully"); + emit phoneStatusMessageChanged(); + emit phonePairingSucceeded(); + }); + watcher->setFuture(future); +} diff --git a/client/ui/controllers/api/pairingUiController.h b/client/ui/controllers/api/pairingUiController.h new file mode 100644 index 000000000..46d93023f --- /dev/null +++ b/client/ui/controllers/api/pairingUiController.h @@ -0,0 +1,86 @@ +#ifndef PAIRINGUICONTROLLER_H +#define PAIRINGUICONTROLLER_H + +#include +#include +#include +#include +#include + +#include "core/controllers/api/pairingController.h" +#include "core/controllers/api/subscriptionController.h" +#include "core/controllers/serversController.h" +#include "core/repositories/secureAppSettingsRepository.h" + +#include "core/utils/errorCodes.h" + +class PairingUiController : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QVariantList tvQrCodes READ tvQrCodes NOTIFY tvQrCodesChanged) + Q_PROPERTY(int tvQrCodesCount READ tvQrCodesCount NOTIFY tvQrCodesChanged) + Q_PROPERTY(QString tvSessionUuid READ tvSessionUuid NOTIFY tvSessionUuidChanged) + Q_PROPERTY(bool tvPairingBusy READ tvPairingBusy NOTIFY tvPairingBusyChanged) + Q_PROPERTY(QString tvStatusMessage READ tvStatusMessage NOTIFY tvStatusMessageChanged) + + Q_PROPERTY(bool phonePairingBusy READ phonePairingBusy NOTIFY phonePairingBusyChanged) + Q_PROPERTY(QString phoneStatusMessage READ phoneStatusMessage NOTIFY phoneStatusMessageChanged) + +public: + PairingUiController(PairingController *pairingController, ServersController *serversController, + SubscriptionController *subscriptionController, SecureAppSettingsRepository *appSettingsRepository, + QObject *parent = nullptr); + + QVariantList tvQrCodes() const; + int tvQrCodesCount() const; + QString tvSessionUuid() const; + bool tvPairingBusy() const; + QString tvStatusMessage() const; + + bool phonePairingBusy() const; + QString phoneStatusMessage() const; + +public slots: + void startTvQrSession(); + void cancelTvQrSession(); + + /** Sends the current premium/free API config from \a serverIndex to the gateway for the given \a qrUuid. */ + void submitPhonePairing(const QString &qrUuid, int serverIndex); + +signals: + void errorOccurred(amnezia::ErrorCode errorCode); + void tvQrCodesChanged(); + void tvSessionUuidChanged(); + void tvPairingBusyChanged(); + void tvStatusMessageChanged(); + void phonePairingBusyChanged(); + void phoneStatusMessageChanged(); + + void tvPairingConfigReceived(); + void phonePairingSucceeded(); + +private: + void setTvBusy(bool busy); + void setPhoneBusy(bool busy); + void resetTvQrDisplay(); + void runPhonePairingRequest(const QString &qrUuid, bool isTestPurchase, const QString &vpnKey, const QJsonObject &serviceInfo, + const QJsonArray &supportedProtocols, const QString &apiKey); + + PairingController *m_pairingController {}; + ServersController *m_serversController {}; + SubscriptionController *m_subscriptionController {}; + SecureAppSettingsRepository *m_appSettingsRepository {}; + + QList m_tvQrCodes; + QString m_tvSessionUuid; + bool m_tvPairingBusy = false; + QString m_tvStatusMessage; + QPointer>> m_tvWatcher; + + bool m_phonePairingBusy = false; + QString m_phoneStatusMessage; + QPointer>> m_phoneWatcher; +}; + +#endif // PAIRINGUICONTROLLER_H diff --git a/client/ui/controllers/qml/pageController.h b/client/ui/controllers/qml/pageController.h index 603e8a8f4..837259c66 100644 --- a/client/ui/controllers/qml/pageController.h +++ b/client/ui/controllers/qml/pageController.h @@ -80,6 +80,8 @@ namespace PageLoader PageSetupWizardApiPremiumInfo, PageSetupWizardApiTrialEmail, + PageSettingsApiQrPairing, + PageDevMenu }; Q_ENUM_NS(PageEnum) diff --git a/client/ui/qml/Pages2/PageSettingsApiQrPairing.qml b/client/ui/qml/Pages2/PageSettingsApiQrPairing.qml new file mode 100644 index 000000000..a96b469e4 --- /dev/null +++ b/client/ui/qml/Pages2/PageSettingsApiQrPairing.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import PageEnum 1.0 +import Style 1.0 + +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + property int qrImageIndex: 0 + + FlickableType { + anchors.fill: parent + contentHeight: layout.implicitHeight + + ColumnLayout { + id: layout + width: root.width + spacing: 8 + + BackButtonType { + Layout.topMargin: 20 + PageController.safeAreaTopMargin + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 8 + text: qsTr("QR pairing") + font.pixelSize: 28 + font.bold: true + color: AmneziaStyle.color.paleGray + wrapMode: Text.Wrap + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: qsTr("Experimental: transfer API configuration to another device via gateway. Use “Receive” on the device that shows the QR code, and “Send” on the premium device.") + wrapMode: Text.Wrap + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 16 + text: qsTr("Receive configuration (TV / second device)") + font.pixelSize: 18 + font.bold: true + color: AmneziaStyle.color.mutedGray + } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: PairingUiController.tvPairingBusy ? qsTr("Waiting…") : qsTr("Start and show QR") + enabled: !PairingUiController.tvPairingBusy && !PairingUiController.phonePairingBusy + clickedFunc: function() { + PairingUiController.startTvQrSession() + } + } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + defaultColor: "transparent" + text: qsTr("Cancel receive") + enabled: PairingUiController.tvPairingBusy + clickedFunc: function() { + PairingUiController.cancelTvQrSession() + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + visible: PairingUiController.tvStatusMessage.length > 0 + text: PairingUiController.tvStatusMessage + wrapMode: Text.Wrap + } + + Image { + id: qrImage + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 8 + visible: PairingUiController.tvQrCodesCount > 0 + width: Math.min(220, parent.width - 32) + height: width + fillMode: Image.PreserveAspectFit + source: PairingUiController.tvQrCodesCount > 0 ? PairingUiController.tvQrCodes[root.qrImageIndex] : "" + + MouseArea { + anchors.fill: parent + enabled: PairingUiController.tvQrCodesCount > 1 + onClicked: { + root.qrImageIndex = (root.qrImageIndex + 1) % PairingUiController.tvQrCodesCount + } + } + } + + Label { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.topMargin: 24 + text: qsTr("Send configuration (premium device)") + font.pixelSize: 18 + font.bold: true + color: AmneziaStyle.color.mutedGray + } + + TextFieldWithHeaderType { + id: uuidField + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + headerText: qsTr("QR session UUID") + textField.placeholderText: qsTr("Paste UUID from TV QR") + } + + BasicButtonType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + text: PairingUiController.phonePairingBusy ? qsTr("Sending…") : qsTr("Send from current subscription") + enabled: !PairingUiController.tvPairingBusy && !PairingUiController.phonePairingBusy + clickedFunc: function() { + PairingUiController.submitPhonePairing(uuidField.textField.text, ServersUiController.getProcessedServerIndex()) + } + } + + ParagraphTextType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + Layout.bottomMargin: 24 + PageController.safeAreaBottomMargin + visible: PairingUiController.phoneStatusMessage.length > 0 + text: PairingUiController.phoneStatusMessage + wrapMode: Text.Wrap + } + } + } + + Connections { + target: PairingUiController + + function onTvQrCodesChanged() { + root.qrImageIndex = 0 + } + + function onTvPairingConfigReceived() { + PageController.showNotificationMessage(qsTr("Configuration received from gateway")) + } + + function onPhonePairingSucceeded() { + PageController.showNotificationMessage(qsTr("Configuration sent")) + } + } +} diff --git a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml index 7eb119244..0a8154d69 100644 --- a/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml +++ b/client/ui/qml/Pages2/PageSettingsApiServerInfo.qml @@ -375,6 +375,20 @@ PageType { visible: footer.isVisibleForAmneziaFree } + LabelWithButtonType { + Layout.fillWidth: true + + text: qsTr("QR pairing (beta)") + descriptionText: qsTr("Transfer config via gateway using a QR code") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + + clickedFunction: function() { + PageController.goToPage(PageEnum.PageSettingsApiQrPairing) + } + } + + DividerType {} + LabelWithButtonType { Layout.fillWidth: true Layout.topMargin: footer.isVisibleForAmneziaFree ? 0 : 32 diff --git a/client/ui/qml/qml.qrc b/client/ui/qml/qml.qrc index f2a462630..30084b6b9 100644 --- a/client/ui/qml/qml.qrc +++ b/client/ui/qml/qml.qrc @@ -85,6 +85,7 @@ Pages2/PageSettingsAbout.qml Pages2/PageSettingsApiAvailableCountries.qml Pages2/PageSettingsApiServerInfo.qml + Pages2/PageSettingsApiQrPairing.qml Pages2/PageSettingsApplication.qml Pages2/PageSettingsAppSplitTunneling.qml Pages2/PageSettingsBackup.qml