mirror of
https://github.com/amnezia-vpn/amnezia-client.git
synced 2026-05-08 14:33:23 +00:00
feat/Implement QR code generation and scanning
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
203
client/core/controllers/api/pairingController.cpp
Normal file
203
client/core/controllers/api/pairingController.cpp
Normal file
@@ -0,0 +1,203 @@
|
||||
#include "pairingController.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
#include <QSysInfo>
|
||||
|
||||
#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);
|
||||
}
|
||||
45
client/core/controllers/api/pairingController.h
Normal file
45
client/core/controllers/api/pairingController.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#ifndef PAIRINGCONTROLLER_H
|
||||
#define PAIRINGCONTROLLER_H
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
#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
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ErrorCode>(&PageController::showErrorMessage));
|
||||
|
||||
connect(m_coreController->m_pairingUiController, &PairingUiController::errorOccurred, m_coreController->m_pageController,
|
||||
qOverload<ErrorCode>(&PageController::showErrorMessage));
|
||||
|
||||
connect(m_coreController->m_settingsUiController, &SettingsUiController::errorOccurred, m_coreController->m_pageController,
|
||||
qOverload<ErrorCode>(&PageController::showErrorMessage));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -132,6 +132,36 @@ apiDefs::ConfigSource apiUtils::getConfigSource(const QJsonObject &serverConfigO
|
||||
return static_cast<apiDefs::ConfigSource>(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<QSslError> &sslErrors, const QString &replyErrorString,
|
||||
const QNetworkReply::NetworkError &replyError, const int httpStatusCode,
|
||||
const QByteArray &responseBody)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef APIUTILS_H
|
||||
#define APIUTILS_H
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkReply>
|
||||
#include <QObject>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
279
client/ui/controllers/api/pairingUiController.cpp
Normal file
279
client/ui/controllers/api/pairingUiController.cpp
Normal file
@@ -0,0 +1,279 @@
|
||||
#include "pairingUiController.h"
|
||||
|
||||
#include <QUuid>
|
||||
|
||||
#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<GatewayController>::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<QPair<ErrorCode, QByteArray>> future = gatewayController->postAsync(QString::fromLatin1(kGenerateQrPath), payload);
|
||||
|
||||
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
|
||||
m_tvWatcher = watcher;
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::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<ApiV2ServerConfig>();
|
||||
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<GatewayController>::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<QPair<ErrorCode, QByteArray>> future = gatewayController->postAsync(QString::fromLatin1(kScanQrPath), payload);
|
||||
|
||||
auto *watcher = new QFutureWatcher<QPair<ErrorCode, QByteArray>>(this);
|
||||
m_phoneWatcher = watcher;
|
||||
QObject::connect(watcher, &QFutureWatcher<QPair<ErrorCode, QByteArray>>::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);
|
||||
}
|
||||
86
client/ui/controllers/api/pairingUiController.h
Normal file
86
client/ui/controllers/api/pairingUiController.h
Normal file
@@ -0,0 +1,86 @@
|
||||
#ifndef PAIRINGUICONTROLLER_H
|
||||
#define PAIRINGUICONTROLLER_H
|
||||
|
||||
#include <QFutureWatcher>
|
||||
#include <QObject>
|
||||
#include <QVariantList>
|
||||
#include <QPointer>
|
||||
#include <QStringList>
|
||||
|
||||
#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<QString> m_tvQrCodes;
|
||||
QString m_tvSessionUuid;
|
||||
bool m_tvPairingBusy = false;
|
||||
QString m_tvStatusMessage;
|
||||
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_tvWatcher;
|
||||
|
||||
bool m_phonePairingBusy = false;
|
||||
QString m_phoneStatusMessage;
|
||||
QPointer<QFutureWatcher<QPair<amnezia::ErrorCode, QByteArray>>> m_phoneWatcher;
|
||||
};
|
||||
|
||||
#endif // PAIRINGUICONTROLLER_H
|
||||
@@ -80,6 +80,8 @@ namespace PageLoader
|
||||
PageSetupWizardApiPremiumInfo,
|
||||
PageSetupWizardApiTrialEmail,
|
||||
|
||||
PageSettingsApiQrPairing,
|
||||
|
||||
PageDevMenu
|
||||
};
|
||||
Q_ENUM_NS(PageEnum)
|
||||
|
||||
171
client/ui/qml/Pages2/PageSettingsApiQrPairing.qml
Normal file
171
client/ui/qml/Pages2/PageSettingsApiQrPairing.qml
Normal file
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
<file>Pages2/PageSettingsAbout.qml</file>
|
||||
<file>Pages2/PageSettingsApiAvailableCountries.qml</file>
|
||||
<file>Pages2/PageSettingsApiServerInfo.qml</file>
|
||||
<file>Pages2/PageSettingsApiQrPairing.qml</file>
|
||||
<file>Pages2/PageSettingsApplication.qml</file>
|
||||
<file>Pages2/PageSettingsAppSplitTunneling.qml</file>
|
||||
<file>Pages2/PageSettingsBackup.qml</file>
|
||||
|
||||
Reference in New Issue
Block a user