From 5a192cec1515c85e2136cf9c34c8432acaeff28a Mon Sep 17 00:00:00 2001 From: dranik Date: Thu, 7 May 2026 23:37:48 +0300 Subject: [PATCH] fixed QR scaner --- client/CMakeLists.txt | 8 -- .../controllers/api/pairingController.cpp | 2 +- .../api/subscriptionController.cpp | 21 ----- client/core/controllers/gatewayController.cpp | 27 +++++-- .../secureAppSettingsRepository.cpp | 2 +- client/core/utils/api/apiUtils.cpp | 4 + client/core/utils/qrCodeUtils.cpp | 9 +++ client/core/utils/qrCodeUtils.h | 2 + .../controllers/api/pairingUiController.cpp | 81 ++++++++++++++++--- .../Pages2/PageSettingsApiQrPairingDev.qml | 14 +++- .../Pages2/PageSettingsApiQrPairingSend.qml | 15 +++- 11 files changed, 132 insertions(+), 53 deletions(-) diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index c5d8b959f..5c847b774 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -214,14 +214,6 @@ if(AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY) target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY) endif() -option(AMNEZIA_LAN_PLAINTEXT_GATEWAY "Dev: plaintext JSON to private LAN gateway hosts (requires AMNEZIA_QR_PAIRING_ALLOW)" OFF) -if(AMNEZIA_LAN_PLAINTEXT_GATEWAY) - if(NOT AMNEZIA_QR_PAIRING_ALLOW) - message(FATAL_ERROR "AMNEZIA_LAN_PLAINTEXT_GATEWAY=ON requires AMNEZIA_QR_PAIRING_ALLOW=ON") - endif() - target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_LAN_PLAINTEXT_GATEWAY) -endif() - target_sources(${PROJECT} PRIVATE ${SOURCES} ${HEADERS} ${RESOURCES} ${QRC} ${I18NQRC}) # Finalize the executable so Qt can gather/deploy QML modules and plugins correctly (Android needs this). diff --git a/client/core/controllers/api/pairingController.cpp b/client/core/controllers/api/pairingController.cpp index 63725658a..0c69dda4e 100644 --- a/client/core/controllers/api/pairingController.cpp +++ b/client/core/controllers/api/pairingController.cpp @@ -30,7 +30,7 @@ bool isLocalGatewayHost(const QString &gatewayUrl) || gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive)) { return true; } -#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY +#ifdef AMNEZIA_QR_PAIRING_ALLOW const QUrl u(gatewayUrl); return NetworkUtilities::hostIsPrivateLanAddress(u.host()); #else diff --git a/client/core/controllers/api/subscriptionController.cpp b/client/core/controllers/api/subscriptionController.cpp index 677fde356..ac89113a0 100644 --- a/client/core/controllers/api/subscriptionController.cpp +++ b/client/core/controllers/api/subscriptionController.cpp @@ -1,6 +1,5 @@ #include "subscriptionController.h" -#include #include #include #include @@ -339,9 +338,7 @@ ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QStrin if (duplicateServerIndex) { *duplicateServerIndex = i; } -#ifndef AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY return ErrorCode::ApiConfigAlreadyAdded; -#endif } } @@ -387,24 +384,6 @@ ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QStrin apiV2->apiConfig.vpnKey = fullKey; } -#ifdef AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY - static int existingSameKeyCount = 0; - if (apiV2) { - ++existingSameKeyCount; - const QString suffix = QCoreApplication::translate("SubscriptionController", " (paired copy %1)") - .arg(existingSameKeyCount); - - if (!apiV2->name.isEmpty()) { - apiV2->name += suffix; - } else if (!apiV2->description.isEmpty()) { - apiV2->description += suffix; - } else { - apiV2->name = QCoreApplication::translate("SubscriptionController", "Paired subscription %1") - .arg(existingSameKeyCount); - } - } -#endif - m_serversRepository->addServer(serverConfigModel); serverConfig = serverConfigModel; return ErrorCode::NoError; diff --git a/client/core/controllers/gatewayController.cpp b/client/core/controllers/gatewayController.cpp index b09a28047..5bfa19720 100644 --- a/client/core/controllers/gatewayController.cpp +++ b/client/core/controllers/gatewayController.cpp @@ -58,12 +58,25 @@ namespace constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); constexpr int proxyStorageRequestTimeoutMsecs = 3000; -} + + /** Pairing/subscription paths use "%1api/v1/..." / "%1v1/..." — %1 must end with '/' or the host and path merge (404). */ + QString normalizedGatewayBase(const QString &endpoint) + { + QString e = endpoint.trimmed(); + if (e.isEmpty()) { + return e; + } + if (!e.endsWith(QLatin1Char('/'))) { + e.append(QLatin1Char('/')); + } + return e; + } +} // namespace GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs, const bool isStrictKillSwitchEnabled, QObject *parent) : QObject(parent), - m_gatewayEndpoint(gatewayEndpoint), + m_gatewayEndpoint(normalizedGatewayBase(gatewayEndpoint)), m_isDevEnvironment(isDevEnvironment), m_requestTimeoutMsecs(requestTimeoutMsecs), m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled) @@ -104,12 +117,10 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const { const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl); const QString host = gatewayUrl.host().toLower(); - bool usePlaintext = (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")); -#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY - if (!usePlaintext) { - usePlaintext = NetworkUtilities::hostIsPrivateLanAddress(host); - } -#endif + const bool loopback = + (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")); + // tools/local_gateway on a LAN IP (e.g. phone → Mac -auto-public): mock expects plaintext JSON. + const bool usePlaintext = loopback || NetworkUtilities::hostIsPrivateLanAddress(host); if (usePlaintext) { encRequestData.isPlaintextLocalGateway = true; encRequestData.requestBody = QJsonDocument(apiPayload).toJson(); diff --git a/client/core/repositories/secureAppSettingsRepository.cpp b/client/core/repositories/secureAppSettingsRepository.cpp index c1da2de40..43e5ee769 100644 --- a/client/core/repositories/secureAppSettingsRepository.cpp +++ b/client/core/repositories/secureAppSettingsRepository.cpp @@ -261,7 +261,7 @@ QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) con || base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) { return m_gatewayEndpoint; } -#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY +#ifdef AMNEZIA_QR_PAIRING_ALLOW { const QUrl gatewayUrl(base); if (NetworkUtilities::hostIsPrivateLanAddress(gatewayUrl.host())) { diff --git a/client/core/utils/api/apiUtils.cpp b/client/core/utils/api/apiUtils.cpp index ec017f64e..6879ddf4d 100644 --- a/client/core/utils/api/apiUtils.cpp +++ b/client/core/utils/api/apiUtils.cpp @@ -221,6 +221,10 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList &ssl return amnezia::ErrorCode::ApiConfigDownloadError; } + if (httpStatusCode == httpStatusCodeNotFound) { + return amnezia::ErrorCode::ApiNotFoundError; + } + qDebug() << "something went wrong"; return amnezia::ErrorCode::ApiConfigDownloadError; } diff --git a/client/core/utils/qrCodeUtils.cpp b/client/core/utils/qrCodeUtils.cpp index a18af1726..e92abefe3 100644 --- a/client/core/utils/qrCodeUtils.cpp +++ b/client/core/utils/qrCodeUtils.cpp @@ -2,6 +2,15 @@ #include #include +#include + +QList qrCodeUtils::generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text) +{ + const QString text = QString::fromUtf8(utf8Text); + qrcodegen::QrCode qr = qrcodegen::QrCode::encodeText(text.toUtf8().constData(), qrcodegen::QrCode::Ecc::LOW); + const QString svg = QString::fromStdString(toSvgString(qr, 1)); + return { svgToBase64(svg) }; +} QList qrCodeUtils::generateQrCodeImageSeries(const QByteArray &data) { diff --git a/client/core/utils/qrCodeUtils.h b/client/core/utils/qrCodeUtils.h index cda0723b2..1c966b881 100644 --- a/client/core/utils/qrCodeUtils.h +++ b/client/core/utils/qrCodeUtils.h @@ -10,6 +10,8 @@ namespace qrCodeUtils constexpr const qint16 qrMagicCode = 1984; QList generateQrCodeImageSeries(const QByteArray &data); + /** QR payload is raw UTF-8 text (e.g. hyphenated session UUID) so phone cameras return a parsable string. */ + QList generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text); qrcodegen::QrCode generateQrCode(const QByteArray &data); QString svgToBase64(const QString &image); }; diff --git a/client/ui/controllers/api/pairingUiController.cpp b/client/ui/controllers/api/pairingUiController.cpp index 35aeea4cf..e7830975e 100644 --- a/client/ui/controllers/api/pairingUiController.cpp +++ b/client/ui/controllers/api/pairingUiController.cpp @@ -1,6 +1,8 @@ #include "pairingUiController.h" +#include #include +#include #include #include #include @@ -40,6 +42,50 @@ int pairingRetryDelayMs(int zeroBasedAttempt) constexpr int baseMs = 500; return baseMs * (1 << zeroBasedAttempt); } + +/** Legacy TV QR: generateQrCodeImageSeries base64url-wrapped QDataStream chunk (see qrCodeUtils.cpp). */ +bool tryDecodeLegacyChunkedPairingQrPayload(const QString &t, QString *outUuid) +{ + static const QRegularExpression binUrlSafe(QStringLiteral("^[A-Za-z0-9_-]+$")); + if (!binUrlSafe.match(t).hasMatch() || t.size() < 16) { + return false; + } + QByteArray padded = t.toUtf8(); + switch (padded.size() % 4) { + case 2: + padded += "=="; + break; + case 3: + padded += "="; + break; + default: + break; + } + const QByteArray raw = QByteArray::fromBase64(padded, QByteArray::Base64UrlEncoding); + if (raw.isEmpty()) { + return false; + } + QDataStream ds(raw); + ds.setByteOrder(QDataStream::BigEndian); + qint16 magic = 0; + quint8 nChunks = 0; + quint8 chunkIndex = 0; + QByteArray pl; + ds >> magic >> nChunks >> chunkIndex >> pl; + if (ds.status() != QDataStream::Ok) { + return false; + } + if (magic != qrCodeUtils::qrMagicCode || nChunks < 1 || nChunks > 200 || chunkIndex >= nChunks) { + return false; + } + const QString candidate = QString::fromUtf8(pl).trimmed(); + const QUuid u = QUuid::fromString(candidate); + if (u.isNull()) { + return false; + } + *outUuid = u.toString(QUuid::WithoutBraces); + return true; +} } // namespace #if defined(Q_OS_ANDROID) @@ -96,17 +142,30 @@ bool PairingUiController::applyScannedTextAsPairingUuid(const QString &raw) qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID"; return false; } - static const QRegularExpression re(QStringLiteral( + static const QRegularExpression reV4(QStringLiteral( "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}")); - const QRegularExpressionMatch m = re.match(t); - if (!m.hasMatch()) { - qInfo() << "[PairingUi] scan rejected: no UUID v4 pattern in payload"; - return false; + const QRegularExpressionMatch m = reV4.match(t); + if (m.hasMatch()) { + const QString uuid = m.captured(0); + qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "..."; + emit pairingUuidFromScan(uuid); + return true; } - const QString uuid = m.captured(0); - qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "..."; - emit pairingUuidFromScan(uuid); - return true; + QString fromLegacy; + if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) { + qInfo() << "[PairingUi] scan accepted legacy chunked QR uuid=" << fromLegacy.left(13) << "..."; + emit pairingUuidFromScan(fromLegacy); + return true; + } + const QUuid parsed = QUuid::fromString(t); + if (!parsed.isNull()) { + const QString canon = parsed.toString(QUuid::WithoutBraces); + qInfo() << "[PairingUi] scan accepted QUuid::fromString uuid=" << canon.left(13) << "..."; + emit pairingUuidFromScan(canon); + return true; + } + qInfo() << "[PairingUi] scan rejected: no session UUID recognized in payload"; + return false; } #if defined(Q_OS_ANDROID) @@ -201,6 +260,8 @@ QString PairingUiController::tvFailureMessage(ErrorCode code) const return tr("QR session expired. Tap Start to show a new QR code."); case ErrorCode::ApiConfigAlreadyAdded: return tr("This configuration is already on the device."); + case ErrorCode::ApiNotFoundError: + return tr("This gateway does not expose QR pairing (HTTP 404). Check the gateway URL or use the local mock (tools/local_gateway)."); default: return tr("Pairing failed"); } @@ -226,7 +287,7 @@ void PairingUiController::startTvQrSession() m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces); const QByteArray qrPayload = m_tvSessionUuid.toUtf8(); - m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeries(qrPayload); + m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeriesPlainText(qrPayload); emit tvQrCodesChanged(); emit tvSessionUuidChanged(); diff --git a/client/ui/qml/Pages2/PageSettingsApiQrPairingDev.qml b/client/ui/qml/Pages2/PageSettingsApiQrPairingDev.qml index fc0a68544..d207e3f6b 100644 --- a/client/ui/qml/Pages2/PageSettingsApiQrPairingDev.qml +++ b/client/ui/qml/Pages2/PageSettingsApiQrPairingDev.qml @@ -15,6 +15,17 @@ PageType { property int qrImageIndex: 0 property bool pairingCameraOpen: false + property int lastPairingScanToastClockMs: 0 + + function notifyPairingScanSuccess() { + const now = new Date().getTime() + if (now - root.lastPairingScanToastClockMs < 1600) { + return + } + root.lastPairingScanToastClockMs = now + PageController.showNotificationMessage( + qsTr("QR session ID captured. Tap Send from current subscription to complete pairing.")) + } Timer { id: pairingCameraKickTimer @@ -238,13 +249,12 @@ PageType { QRCodeReader { id: pairingQrReader - anchors.fill: parent onCodeReaded: function(code) { if (PairingUiController.applyScannedTextAsPairingUuid(code)) { pairingQrReader.stopReading() root.pairingCameraOpen = false - PageController.showNotificationMessage(qsTr("Session ID filled from QR")) + root.notifyPairingScanSuccess() } } } diff --git a/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml b/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml index 05966d1c3..cdf5fce1d 100644 --- a/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml +++ b/client/ui/qml/Pages2/PageSettingsApiQrPairingSend.qml @@ -14,6 +14,18 @@ PageType { id: root property bool pairingCameraOpen: false + /** iOS AVFoundation can fire the same QR repeatedly; avoid stacking identical toasts. */ + property int lastPairingScanToastClockMs: 0 + + function notifyPairingScanSuccess() { + const now = new Date().getTime() + if (now - root.lastPairingScanToastClockMs < 1600) { + return + } + root.lastPairingScanToastClockMs = now + PageController.showNotificationMessage( + qsTr("QR session ID captured. Tap Send from current subscription to complete pairing.")) + } Timer { id: pairingCameraKickTimer @@ -160,13 +172,12 @@ PageType { QRCodeReader { id: pairingQrReader - anchors.fill: parent onCodeReaded: function(code) { if (PairingUiController.applyScannedTextAsPairingUuid(code)) { pairingQrReader.stopReading() root.pairingCameraOpen = false - PageController.showNotificationMessage(qsTr("Session ID filled from QR")) + root.notifyPairingScanSuccess() } } }