fixed QR scaner

This commit is contained in:
dranik
2026-05-07 23:37:48 +03:00
parent 6fc65dba8a
commit 5a192cec15
11 changed files with 132 additions and 53 deletions

View File

@@ -214,14 +214,6 @@ if(AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY)
target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY) target_compile_definitions(${PROJECT} PRIVATE AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY)
endif() 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}) 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). # Finalize the executable so Qt can gather/deploy QML modules and plugins correctly (Android needs this).

View File

@@ -30,7 +30,7 @@ bool isLocalGatewayHost(const QString &gatewayUrl)
|| gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive)) { || gatewayUrl.contains(QStringLiteral("::1"), Qt::CaseInsensitive)) {
return true; return true;
} }
#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY #ifdef AMNEZIA_QR_PAIRING_ALLOW
const QUrl u(gatewayUrl); const QUrl u(gatewayUrl);
return NetworkUtilities::hostIsPrivateLanAddress(u.host()); return NetworkUtilities::hostIsPrivateLanAddress(u.host());
#else #else

View File

@@ -1,6 +1,5 @@
#include "subscriptionController.h" #include "subscriptionController.h"
#include <QCoreApplication>
#include <QDebug> #include <QDebug>
#include <QDateTime> #include <QDateTime>
#include <QEventLoop> #include <QEventLoop>
@@ -339,9 +338,7 @@ ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QStrin
if (duplicateServerIndex) { if (duplicateServerIndex) {
*duplicateServerIndex = i; *duplicateServerIndex = i;
} }
#ifndef AMNEZIA_QR_PAIRING_ALLOW_DUPLICATE_VPN_KEY
return ErrorCode::ApiConfigAlreadyAdded; return ErrorCode::ApiConfigAlreadyAdded;
#endif
} }
} }
@@ -387,24 +384,6 @@ ErrorCode SubscriptionController::importServerFromQrPairingResponse(const QStrin
apiV2->apiConfig.vpnKey = fullKey; 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); m_serversRepository->addServer(serverConfigModel);
serverConfig = serverConfigModel; serverConfig = serverConfigModel;
return ErrorCode::NoError; return ErrorCode::NoError;

View File

@@ -58,12 +58,25 @@ namespace
constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?"); constexpr QLatin1String unprocessableSubscriptionMessage("Failed to retrieve subscription information. Is it activated?");
constexpr int proxyStorageRequestTimeoutMsecs = 3000; 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, GatewayController::GatewayController(const QString &gatewayEndpoint, const bool isDevEnvironment, const int requestTimeoutMsecs,
const bool isStrictKillSwitchEnabled, QObject *parent) const bool isStrictKillSwitchEnabled, QObject *parent)
: QObject(parent), : QObject(parent),
m_gatewayEndpoint(gatewayEndpoint), m_gatewayEndpoint(normalizedGatewayBase(gatewayEndpoint)),
m_isDevEnvironment(isDevEnvironment), m_isDevEnvironment(isDevEnvironment),
m_requestTimeoutMsecs(requestTimeoutMsecs), m_requestTimeoutMsecs(requestTimeoutMsecs),
m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled) m_isStrictKillSwitchEnabled(isStrictKillSwitchEnabled)
@@ -104,12 +117,10 @@ GatewayController::EncryptedRequestData GatewayController::prepareRequest(const
{ {
const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl); const QUrl gatewayUrl(m_proxyUrl.isEmpty() ? m_gatewayEndpoint : m_proxyUrl);
const QString host = gatewayUrl.host().toLower(); const QString host = gatewayUrl.host().toLower();
bool usePlaintext = (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1")); const bool loopback =
#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY (host == QLatin1String("localhost") || host == QLatin1String("127.0.0.1") || host == QLatin1String("::1"));
if (!usePlaintext) { // tools/local_gateway on a LAN IP (e.g. phone → Mac -auto-public): mock expects plaintext JSON.
usePlaintext = NetworkUtilities::hostIsPrivateLanAddress(host); const bool usePlaintext = loopback || NetworkUtilities::hostIsPrivateLanAddress(host);
}
#endif
if (usePlaintext) { if (usePlaintext) {
encRequestData.isPlaintextLocalGateway = true; encRequestData.isPlaintextLocalGateway = true;
encRequestData.requestBody = QJsonDocument(apiPayload).toJson(); encRequestData.requestBody = QJsonDocument(apiPayload).toJson();

View File

@@ -261,7 +261,7 @@ QString SecureAppSettingsRepository::getGatewayEndpoint(bool isTestPurchase) con
|| base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) { || base.contains(QStringLiteral("[::1]"), Qt::CaseInsensitive)) {
return m_gatewayEndpoint; return m_gatewayEndpoint;
} }
#ifdef AMNEZIA_LAN_PLAINTEXT_GATEWAY #ifdef AMNEZIA_QR_PAIRING_ALLOW
{ {
const QUrl gatewayUrl(base); const QUrl gatewayUrl(base);
if (NetworkUtilities::hostIsPrivateLanAddress(gatewayUrl.host())) { if (NetworkUtilities::hostIsPrivateLanAddress(gatewayUrl.host())) {

View File

@@ -221,6 +221,10 @@ amnezia::ErrorCode apiUtils::checkNetworkReplyErrors(const QList<QSslError> &ssl
return amnezia::ErrorCode::ApiConfigDownloadError; return amnezia::ErrorCode::ApiConfigDownloadError;
} }
if (httpStatusCode == httpStatusCodeNotFound) {
return amnezia::ErrorCode::ApiNotFoundError;
}
qDebug() << "something went wrong"; qDebug() << "something went wrong";
return amnezia::ErrorCode::ApiConfigDownloadError; return amnezia::ErrorCode::ApiConfigDownloadError;
} }

View File

@@ -2,6 +2,15 @@
#include <QIODevice> #include <QIODevice>
#include <QList> #include <QList>
#include <QString>
QList<QString> 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<QString> qrCodeUtils::generateQrCodeImageSeries(const QByteArray &data) QList<QString> qrCodeUtils::generateQrCodeImageSeries(const QByteArray &data)
{ {

View File

@@ -10,6 +10,8 @@ namespace qrCodeUtils
constexpr const qint16 qrMagicCode = 1984; constexpr const qint16 qrMagicCode = 1984;
QList<QString> generateQrCodeImageSeries(const QByteArray &data); QList<QString> generateQrCodeImageSeries(const QByteArray &data);
/** QR payload is raw UTF-8 text (e.g. hyphenated session UUID) so phone cameras return a parsable string. */
QList<QString> generateQrCodeImageSeriesPlainText(const QByteArray &utf8Text);
qrcodegen::QrCode generateQrCode(const QByteArray &data); qrcodegen::QrCode generateQrCode(const QByteArray &data);
QString svgToBase64(const QString &image); QString svgToBase64(const QString &image);
}; };

View File

@@ -1,6 +1,8 @@
#include "pairingUiController.h" #include "pairingUiController.h"
#include <QDataStream>
#include <QDebug> #include <QDebug>
#include <QIODevice>
#include <QRegularExpression> #include <QRegularExpression>
#include <QTimer> #include <QTimer>
#include <QUuid> #include <QUuid>
@@ -40,6 +42,50 @@ int pairingRetryDelayMs(int zeroBasedAttempt)
constexpr int baseMs = 500; constexpr int baseMs = 500;
return baseMs * (1 << zeroBasedAttempt); 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 } // namespace
#if defined(Q_OS_ANDROID) #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"; qInfo() << "[PairingUi] scan rejected: looks like vpn:// bundle, not session UUID";
return false; 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}")); "[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); const QRegularExpressionMatch m = reV4.match(t);
if (!m.hasMatch()) { if (m.hasMatch()) {
qInfo() << "[PairingUi] scan rejected: no UUID v4 pattern in payload"; const QString uuid = m.captured(0);
return false; qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "...";
emit pairingUuidFromScan(uuid);
return true;
} }
const QString uuid = m.captured(0); QString fromLegacy;
qInfo() << "[PairingUi] scan accepted uuid=" << uuid.left(13) << "..."; if (tryDecodeLegacyChunkedPairingQrPayload(t, &fromLegacy)) {
emit pairingUuidFromScan(uuid); qInfo() << "[PairingUi] scan accepted legacy chunked QR uuid=" << fromLegacy.left(13) << "...";
return true; 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) #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."); return tr("QR session expired. Tap Start to show a new QR code.");
case ErrorCode::ApiConfigAlreadyAdded: case ErrorCode::ApiConfigAlreadyAdded:
return tr("This configuration is already on the device."); 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: default:
return tr("Pairing failed"); return tr("Pairing failed");
} }
@@ -226,7 +287,7 @@ void PairingUiController::startTvQrSession()
m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces); m_tvSessionUuid = QUuid::createUuid().toString(QUuid::WithoutBraces);
const QByteArray qrPayload = m_tvSessionUuid.toUtf8(); const QByteArray qrPayload = m_tvSessionUuid.toUtf8();
m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeries(qrPayload); m_tvQrCodes = qrCodeUtils::generateQrCodeImageSeriesPlainText(qrPayload);
emit tvQrCodesChanged(); emit tvQrCodesChanged();
emit tvSessionUuidChanged(); emit tvSessionUuidChanged();

View File

@@ -15,6 +15,17 @@ PageType {
property int qrImageIndex: 0 property int qrImageIndex: 0
property bool pairingCameraOpen: false 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 { Timer {
id: pairingCameraKickTimer id: pairingCameraKickTimer
@@ -238,13 +249,12 @@ PageType {
QRCodeReader { QRCodeReader {
id: pairingQrReader id: pairingQrReader
anchors.fill: parent
onCodeReaded: function(code) { onCodeReaded: function(code) {
if (PairingUiController.applyScannedTextAsPairingUuid(code)) { if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
pairingQrReader.stopReading() pairingQrReader.stopReading()
root.pairingCameraOpen = false root.pairingCameraOpen = false
PageController.showNotificationMessage(qsTr("Session ID filled from QR")) root.notifyPairingScanSuccess()
} }
} }
} }

View File

@@ -14,6 +14,18 @@ PageType {
id: root id: root
property bool pairingCameraOpen: false 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 { Timer {
id: pairingCameraKickTimer id: pairingCameraKickTimer
@@ -160,13 +172,12 @@ PageType {
QRCodeReader { QRCodeReader {
id: pairingQrReader id: pairingQrReader
anchors.fill: parent
onCodeReaded: function(code) { onCodeReaded: function(code) {
if (PairingUiController.applyScannedTextAsPairingUuid(code)) { if (PairingUiController.applyScannedTextAsPairingUuid(code)) {
pairingQrReader.stopReading() pairingQrReader.stopReading()
root.pairingCameraOpen = false root.pairingCameraOpen = false
PageController.showNotificationMessage(qsTr("Session ID filled from QR")) root.notifyPairingScanSuccess()
} }
} }
} }